diff --git a/.gitignore b/.gitignore index 49bd59a..2d55045 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ agent-orchestrator.yaml !/skills/dart-gherkin-tests *.g.dart **/*.exe +/CWS*.yaml +/CWS*.yml +dart_test.yaml diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index 7116c71..f5756b8 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -7,6 +7,7 @@ import '../config/app_logger.dart'; import 'cli_responder.dart'; import '../config/config.dart'; import 'database.dart'; +import 'process_launcher.dart'; import '../issue_tracker/issue_event_source/base.dart'; import '../issue_tracker/issue_event_source/gitea_gosmee.dart'; import '../issue_tracker/issue_event_source/gitlab_gosmee.dart'; @@ -46,12 +47,17 @@ class IssueAssistantApp { String glabCommand = 'glab', String gosmeeCommand = 'gosmee', String teaCommand = 'tea', + CliResponderRunner? responderRunner, + ExternalCommandExecutor? commandExecutor, }) async { final database = AppDatabase.open(databasePath); + final externalCommandExecutor = + commandExecutor ?? const DefaultExternalCommandExecutor(); final issueTrackerClient = IssueTrackerClient( ghCommand: ghCommand, glabCommand: glabCommand, teaCommand: teaCommand, + commandExecutor: externalCommandExecutor, ); final pollingIssueEventSource = PollingIssueEventSource( database: database, @@ -71,26 +77,30 @@ class IssueAssistantApp { IssueTrackerEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( issueTrackerClient: issueTrackerClient, gosmeeCommand: gosmeeCommand, + commandExecutor: externalCommandExecutor, logger: _logger, ), IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource( issueTrackerClient: issueTrackerClient, gosmeeCommand: gosmeeCommand, + commandExecutor: externalCommandExecutor, logger: _logger, ), IssueTrackerEventSourceKind.ghWebhookForward: GitHubWebhookForwardIssueEventSource( issueTrackerClient: issueTrackerClient, + commandExecutor: externalCommandExecutor, logger: _logger, ), IssueTrackerEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource( issueTrackerClient: issueTrackerClient, gosmeeCommand: gosmeeCommand, + commandExecutor: externalCommandExecutor, logger: _logger, ), }, ), - responderRunner: CliResponderRunner(), + responderRunner: responderRunner ?? CliResponderRunner(), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs( project.issueAssistant.notifications, diff --git a/lib/src/core/process_launcher.dart b/lib/src/core/process_launcher.dart index 097cb1d..b366613 100644 --- a/lib/src/core/process_launcher.dart +++ b/lib/src/core/process_launcher.dart @@ -10,13 +10,11 @@ class ResolvedExternalCommand { ResolvedExternalCommand({ required this.executable, required this.arguments, - required this.usesBashShim, required this.runInShell, }); final String executable; final List arguments; - final bool usesBashShim; final bool runInShell; } @@ -24,6 +22,100 @@ final AppLogger _processLauncherLogger = AppLogger( 'code_work_spawner.process_launcher', ); +abstract interface class ExternalCommandExecutor { + Future run( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment, + Duration? timeout, + }); + + Future start( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment, + bool runInShell, + ProcessStartMode mode, + }); +} + +abstract interface class StartedExternalCommand { + Stream> get stdout; + Stream> get stderr; + Future get exitCode; + bool kill([ProcessSignal signal = ProcessSignal.sigterm]); +} + +class DefaultExternalCommandExecutor implements ExternalCommandExecutor { + const DefaultExternalCommandExecutor(); + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + Duration? timeout, + }) { + return runExternalCommand( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + timeout: timeout, + ); + } + + @override + Future start( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + return ProcessStartedExternalCommand( + await startExternalCommand( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ), + ); + } +} + +class ProcessStartedExternalCommand implements StartedExternalCommand { + ProcessStartedExternalCommand(this._process); + + final Process _process; + + @override + Stream> get stdout => _process.stdout; + + @override + Stream> get stderr => _process.stderr; + + @override + Future get exitCode => _process.exitCode; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + return _process.kill(signal); + } +} + ResolvedExternalCommand resolveExternalCommandForCurrentPlatform( String executable, List arguments, { @@ -35,27 +127,9 @@ ResolvedExternalCommand resolveExternalCommandForCurrentPlatform( localExecutablePath ?? _resolveExecutablePathOnPath(executable); final effectiveExecutable = resolvedExecutablePath ?? executable; final extension = p.extension(effectiveExecutable).toLowerCase(); - final shouldUseBash = extension.isEmpty || extension == '.sh'; - - if (isWindowsPlatform && shouldUseBash && localExecutablePath != null) { - final bashScriptPath = _toBashPath(localExecutablePath); - _processLauncherLogger.debug( - 'resolved WSL bash shim executable=$localExecutablePath ' - 'bash_script=$bashScriptPath ' - 'args=${_renderLauncherArguments(arguments)}', - ); - return ResolvedExternalCommand( - executable: 'wsl.exe', - arguments: ['--exec', 'bash', bashScriptPath, ...arguments], - usesBashShim: true, - runInShell: false, - ); - } - return ResolvedExternalCommand( executable: effectiveExecutable, arguments: arguments, - usesBashShim: false, // Only batch files require shell mediation on Windows. Native executables // such as Scoop shims should run directly to avoid hanging shell wrappers. runInShell: isWindowsPlatform && _requiresWindowsShell(extension), @@ -87,18 +161,6 @@ bool _looksLikePathReference(String executable) { executable.startsWith('.'); } -String _toBashPath(String windowsPath) { - final normalized = windowsPath.replaceAll('\\', '/'); - final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized); - if (drivePrefixMatch == null) { - return normalized; - } - - final driveLetter = drivePrefixMatch.group(1)!.toLowerCase(); - final rest = drivePrefixMatch.group(2)!; - return '/mnt/$driveLetter/$rest'; -} - Future runExternalCommand( String executable, List arguments, { @@ -111,67 +173,26 @@ Future runExternalCommand( executable, arguments, ); - final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( - environment: environment, - includeParentEnvironment: includeParentEnvironment, - usesBashShim: resolved.usesBashShim, - ); - final mode = Platform.isWindows && !resolved.usesBashShim - ? 'windows-wrapper' - : 'process-run'; + const mode = 'process-start'; _processLauncherLogger.debug( 'launch mode=$mode executable=${resolved.executable} ' 'requested_executable=$executable run_in_shell=${resolved.runInShell} ' - 'uses_bash_shim=${resolved.usesBashShim} ' 'working_directory=${workingDirectory ?? "(inherit)"} ' 'timeout_ms=${timeout?.inMilliseconds ?? 0} ' 'args=${_renderLauncherArguments(resolved.arguments)}', ); - if (Platform.isWindows && !resolved.usesBashShim) { - _processLauncherLogger.debug( - 'process_run in runExternalCommand - windows wrapper', - ); - return _runExternalCommandViaWindowsWrapper( + final stopwatch = Stopwatch()..start(); + try { + _processLauncherLogger.debug('process_start in runExternalCommand'); + final result = await _runExternalCommandWithCapturedOutput( executable: resolved.executable, arguments: resolved.arguments, workingDirectory: workingDirectory, - environment: effectiveEnvironment, + environment: environment, includeParentEnvironment: includeParentEnvironment, + runInShell: resolved.runInShell, timeout: timeout, ); - } - final stopwatch = Stopwatch()..start(); - try { - _processLauncherLogger.debug('process_run in runExternalCommand'); - final result = timeout == null - ? await Process.run( - resolved.executable, - resolved.arguments, - workingDirectory: workingDirectory, - environment: effectiveEnvironment, - includeParentEnvironment: includeParentEnvironment, - runInShell: resolved.runInShell, - stdoutEncoding: utf8, - stderrEncoding: utf8, - ) - : await Process.run( - resolved.executable, - resolved.arguments, - workingDirectory: workingDirectory, - environment: effectiveEnvironment, - includeParentEnvironment: includeParentEnvironment, - runInShell: resolved.runInShell, - stdoutEncoding: utf8, - stderrEncoding: utf8, - ).timeout( - timeout, - onTimeout: () { - throw TimeoutException( - 'Command timed out after ${timeout.inMilliseconds}ms.', - timeout, - ); - }, - ); stopwatch.stop(); _processLauncherLogger.debug( 'finished mode=$mode exit_code=${result.exitCode} ' @@ -189,155 +210,47 @@ Future runExternalCommand( } } -Future _runExternalCommandViaWindowsWrapper({ +Future _runExternalCommandWithCapturedOutput({ required String executable, required List arguments, required String? workingDirectory, required Map? environment, required bool includeParentEnvironment, + required bool runInShell, required Duration? timeout, }) async { - const timeoutExitCode = 124; - final sandbox = await Directory.systemTemp.createTemp( - 'cws-process-launcher-', + final process = await Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, ); - final stdoutPath = p.join(sandbox.path, 'stdout.txt'); - final stderrPath = p.join(sandbox.path, 'stderr.txt'); - final scriptPath = p.join(sandbox.path, 'run.ps1'); - final script = File(scriptPath); - - _processLauncherLogger.debug('after fopen'); - + final stdoutFuture = process.stdout.transform(utf8.decoder).join(); + final stderrFuture = process.stderr.transform(utf8.decoder).join(); try { - await script.writeAsString( - _buildWindowsWrapperScript( - executable: executable, - arguments: arguments, - workingDirectory: workingDirectory, - stdoutPath: stdoutPath, - stderrPath: stderrPath, - timeout: timeout, - ), - ); - final stopwatch = Stopwatch()..start(); - _processLauncherLogger.debug( - 'wrapper start executable=$executable ' - 'script=${script.path} stdout_path=$stdoutPath stderr_path=$stderrPath', - ); - final wrapperResult = await Process.run( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-File', - script.path, - ], - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - stdoutEncoding: utf8, - stderrEncoding: utf8, - ); - stopwatch.stop(); - final stdoutText = await _readWrapperOutput(stdoutPath); - final stderrText = await _readWrapperOutput(stderrPath); - _processLauncherLogger.debug( - 'wrapper finished exit_code=${wrapperResult.exitCode} ' - 'duration_ms=${stopwatch.elapsedMilliseconds} ' - 'stdout_len=${stdoutText.length} stderr_len=${stderrText.length} ' - 'wrapper_stdout_len=${wrapperResult.stdout.toString().length} ' - 'wrapper_stderr_len=${wrapperResult.stderr.toString().length}', - ); - if (wrapperResult.exitCode == timeoutExitCode) { - throw TimeoutException( - 'Command timed out after ${timeout!.inMilliseconds}ms.', - timeout, - ); - } - return ProcessResult( - wrapperResult.pid, - wrapperResult.exitCode, - stdoutText, - stderrText.isEmpty ? wrapperResult.stderr : stderrText, - ); - } finally { - await sandbox.delete(recursive: true); + final exitCode = timeout == null + ? await process.exitCode + : await process.exitCode.timeout( + timeout, + onTimeout: () { + process.kill(ProcessSignal.sigkill); + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ); + final stdoutText = await stdoutFuture; + final stderrText = await stderrFuture; + return ProcessResult(process.pid, exitCode, stdoutText, stderrText); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + rethrow; } } -String _buildWindowsWrapperScript({ - required String executable, - required List arguments, - required String? workingDirectory, - required String stdoutPath, - required String stderrPath, - required Duration? timeout, -}) { - final timeoutMilliseconds = timeout?.inMilliseconds ?? 0; - return ''' -\$ErrorActionPreference = 'Stop' -\$stdoutPath = ${_powerShellSingleQuoted(stdoutPath)} -\$stderrPath = ${_powerShellSingleQuoted(stderrPath)} -try { - \$commandPath = ${_powerShellSingleQuoted(executable)} - - \$startProcess = @{ - FilePath = \$commandPath - ArgumentList = ${_renderPowerShellArray(arguments)} - PassThru = \$true - WindowStyle = 'Hidden' - RedirectStandardOutput = \$stdoutPath - RedirectStandardError = \$stderrPath - } - if (${workingDirectory == null ? '\$false' : '\$true'}) { - \$startProcess.WorkingDirectory = ${_powerShellSingleQuoted(workingDirectory ?? '')} - } - \$process = Start-Process @startProcess - - \$timedOut = \$false - if ($timeoutMilliseconds -gt 0) { - if (-not \$process.WaitForExit($timeoutMilliseconds)) { - \$timedOut = \$true - try { - Stop-Process -Id \$process.Id -Force - } catch { - } - } - } else { - \$process.WaitForExit() - } - - if (\$timedOut) { - exit 124 - } - - exit \$process.ExitCode -} catch { - Set-Content -LiteralPath \$stderrPath -Value \$_.ToString() -Encoding utf8 - exit 127 -} -'''; -} - -Future _readWrapperOutput(String path) async { - final file = File(path); - if (!await file.exists()) { - return ''; - } - return file.readAsString(); -} - -String _renderPowerShellArray(List arguments) { - final renderedArguments = arguments.map(_powerShellSingleQuoted).join(', '); - return '@($renderedArguments)'; -} - -String _powerShellSingleQuoted(String value) { - return "'${value.replaceAll("'", "''")}'"; -} - String _renderLauncherArguments(List arguments) { return arguments.map(_summarizeLauncherArgument).join(' '); } @@ -365,41 +278,13 @@ Future startExternalCommand( executable, arguments, ); - final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( - environment: environment, - includeParentEnvironment: includeParentEnvironment, - usesBashShim: resolved.usesBashShim, - ); return Process.start( resolved.executable, resolved.arguments, workingDirectory: workingDirectory, - environment: effectiveEnvironment, + environment: environment, includeParentEnvironment: includeParentEnvironment, runInShell: runInShell || resolved.runInShell, mode: mode, ); } - -Map? _effectiveEnvironmentForResolvedCommand({ - required Map? environment, - required bool includeParentEnvironment, - required bool usesBashShim, -}) { - if (!usesBashShim || environment == null || environment.isEmpty) { - return environment; - } - - final effectiveEnvironment = Map.from(environment); - final existingWslenvFromProcess = includeParentEnvironment - ? Platform.environment['WSLENV'] - : null; - final existingWslenv = - effectiveEnvironment['WSLENV'] ?? existingWslenvFromProcess; - final forwardedKeys = { - ...?existingWslenv?.split(':').where((entry) => entry.isNotEmpty), - ...effectiveEnvironment.keys.where((key) => key != 'WSLENV'), - }; - effectiveEnvironment['WSLENV'] = forwardedKeys.join(':'); - return effectiveEnvironment; -} diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 887827e..1816cc9 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -20,8 +20,11 @@ class IssueTrackerClient { this.opCommand = 'op', Duration? commandTimeout, AppLogger? logger, + ExternalCommandExecutor? commandExecutor, }) : commandTimeout = commandTimeout ?? const Duration(seconds: 15), - _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'); + _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'), + _commandExecutor = + commandExecutor ?? const DefaultExternalCommandExecutor(); final String ghCommand; final String glabCommand; @@ -29,6 +32,7 @@ class IssueTrackerClient { final String opCommand; final Duration commandTimeout; final AppLogger _logger; + final ExternalCommandExecutor _commandExecutor; final Map> _authenticatedLoginFutures = >{}; final Map> _openProjectProjectFutures = @@ -792,7 +796,7 @@ class IssueTrackerClient { ); late final ProcessResult result; try { - result = await runExternalCommand( + result = await _commandExecutor.run( command, arguments, timeout: commandTimeout, diff --git a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart index 1b71917..fa7425d 100644 --- a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart @@ -15,6 +15,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { GiteaGosmeeIssueEventSource({ required this.issueTrackerClient, required this.gosmeeCommand, + required this.commandExecutor, required AppLogger logger, }) : _logger = logger; @@ -22,10 +23,11 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; + final ExternalCommandExecutor commandExecutor; final AppLogger _logger; final Map _queuedIssues = {}; final Set> _activeDispatches = >{}; - final Map _gosmeeProcesses = {}; + final Map _gosmeeProcesses = {}; final Map _webhookIdsByProject = {}; final Map _repoSlugsByProject = {}; @@ -109,7 +111,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { 'gitea/gosmee project=${project.key} start_client ' 'command="$gosmeeCommand client $publicUrl $localUrl"', ); - final process = await startExternalCommand(gosmeeCommand, [ + final process = await commandExecutor.start(gosmeeCommand, [ 'client', publicUrl, localUrl, @@ -286,7 +288,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await runExternalCommand(gosmeeCommand, [ + final result = await commandExecutor.run(gosmeeCommand, [ '--output', 'json', 'client', diff --git a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart index 7e1d1ee..26008ad 100644 --- a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart @@ -15,6 +15,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { GitHubGosmeeIssueEventSource({ required this.issueTrackerClient, required this.gosmeeCommand, + required this.commandExecutor, required AppLogger logger, }) : _logger = logger; @@ -22,10 +23,11 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; + final ExternalCommandExecutor commandExecutor; final AppLogger _logger; final Map _queuedIssues = {}; final Set> _activeDispatches = >{}; - final Map _gosmeeProcesses = {}; + final Map _gosmeeProcesses = {}; final Map _webhookIdsByProject = {}; final Map _repoSlugsByProject = {}; @@ -109,7 +111,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { 'github/gosmee project=${project.key} start_client ' 'command="$gosmeeCommand client $publicUrl $localUrl"', ); - final process = await startExternalCommand(gosmeeCommand, [ + final process = await commandExecutor.start(gosmeeCommand, [ 'client', publicUrl, localUrl, @@ -286,7 +288,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await runExternalCommand(gosmeeCommand, [ + final result = await commandExecutor.run(gosmeeCommand, [ '--output', 'json', 'client', diff --git a/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart b/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart index fe883c2..5c43115 100644 --- a/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart +++ b/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart @@ -12,16 +12,18 @@ import 'base.dart'; class GitHubWebhookForwardIssueEventSource implements IssueEventSource { GitHubWebhookForwardIssueEventSource({ required this.issueTrackerClient, + required this.commandExecutor, required AppLogger logger, }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); final IssueTrackerClient issueTrackerClient; + final ExternalCommandExecutor commandExecutor; final AppLogger _logger; final Map _queuedIssues = {}; final Set> _activeDispatches = >{}; - final Map _forwardProcesses = {}; + final Map _forwardProcesses = {}; HttpServer? _server; Timer? _flushTimer; @@ -82,13 +84,14 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource { 'github/webhook-forward project=${project.key} start_forwarder ' 'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"', ); - final process = await startExternalCommand(issueTrackerClient.ghCommand, [ - 'webhook', - 'forward', - '--events=issues,issue_comment', - '--repo=${project.repo}', - '--url=$url', - ]); + final process = await commandExecutor + .start(issueTrackerClient.ghCommand, [ + 'webhook', + 'forward', + '--events=issues,issue_comment', + '--repo=${project.repo}', + '--url=$url', + ]); _forwardProcesses[project.key] = process; _pipeProcessLogs( project: project, diff --git a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart index 7561b60..bea89d9 100644 --- a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart @@ -13,6 +13,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { GitLabGosmeeIssueEventSource({ required this.issueTrackerClient, required this.gosmeeCommand, + required this.commandExecutor, required AppLogger logger, }) : _logger = logger; @@ -20,10 +21,11 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; + final ExternalCommandExecutor commandExecutor; final AppLogger _logger; final Map _queuedIssues = {}; final Set> _activeDispatches = >{}; - final Map _gosmeeProcesses = {}; + final Map _gosmeeProcesses = {}; final Map _webhookIdsByProject = {}; final Map _trackerProjectsByProject = {}; @@ -107,7 +109,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { 'gitlab/gosmee project=${project.key} start_client ' 'command="$gosmeeCommand client $publicUrl $localUrl"', ); - final process = await startExternalCommand(gosmeeCommand, [ + final process = await commandExecutor.start(gosmeeCommand, [ 'client', publicUrl, localUrl, @@ -324,7 +326,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await runExternalCommand(gosmeeCommand, [ + final result = await commandExecutor.run(gosmeeCommand, [ '--output', 'json', 'client', diff --git a/pubspec.yaml b/pubspec.yaml index f27152d..59f8e48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dev_dependencies: drift_dev: ^2.28.1 json_serializable: ">=6.11.1 <6.12.0" lints: ^6.0.0 + mocktail: ^1.0.5 test: ^1.25.6 platforms: diff --git a/test/core/concurrency_test.dart b/test/core/concurrency_test.dart index 71ad180..faa42b0 100644 --- a/test/core/concurrency_test.dart +++ b/test/core/concurrency_test.dart @@ -146,6 +146,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -285,6 +287,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -367,6 +371,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. diff --git a/test/core/core_test.dart b/test/core/core_test.dart index ea455a4..7c9efd8 100644 --- a/test/core/core_test.dart +++ b/test/core/core_test.dart @@ -102,6 +102,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -210,6 +212,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -308,6 +312,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -437,6 +443,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -537,6 +545,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); final logMessages = []; void captureLog(AppLogRecord record) { diff --git a/test/core/multi_project_test.dart b/test/core/multi_project_test.dart index aac6b81..9417d68 100644 --- a/test/core/multi_project_test.dart +++ b/test/core/multi_project_test.dart @@ -173,6 +173,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -318,6 +320,8 @@ CWS_GITEA_TOKEN=test-token final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -419,6 +423,8 @@ OP_CLI_TOKEN=test-token final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. diff --git a/test/core/process_launcher_test.dart b/test/core/process_launcher_test.dart index a676d9d..6d5154f 100644 --- a/test/core/process_launcher_test.dart +++ b/test/core/process_launcher_test.dart @@ -36,7 +36,6 @@ void main() { // And it does not force the command to run in a shell. expect(resolved.runInShell, isFalse); - expect(resolved.usesBashShim, isFalse); }); /// ```gherkin @@ -62,47 +61,40 @@ void main() { // And it marks the command to run in a shell. expect(resolved.runInShell, isTrue); - expect(resolved.usesBashShim, isFalse); }); /// ```gherkin - /// Scenario: Wrap local extensionless scripts through WSL bash on Windows - /// Given a local extensionless script file on Windows + /// Scenario: Leave local Bash scripts unresolved on Windows + /// Given a local Bash script file on Windows /// When the process launcher resolves the command - /// Then it swaps the executable to wsl.exe - /// And it forwards the script path as a bash path + /// Then it leaves the script path unchanged + /// And it does not add a WSL wrapper /// ``` - test( - 'Wrap local extensionless scripts through WSL bash on Windows', - () async { - // Given a local extensionless script file on Windows. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-process-launcher-', + test('Leave local Bash scripts unresolved on Windows', () async { + // Given a local Bash script file on Windows. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-process-launcher-', + ); + final script = File(p.join(sandbox.path, 'fake-tea.sh')); + await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n'); + + try { + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + script.path, + const ['api'], + isWindows: true, ); - final script = File(p.join(sandbox.path, 'fake-tea')); - await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n'); - try { - // When the process launcher resolves the command. - final resolved = resolveExternalCommandForCurrentPlatform( - script.path, - const ['api'], - isWindows: true, - ); + // Then it leaves the script path unchanged. + expect(resolved.executable, script.path); - // Then it swaps the executable to wsl.exe. - expect(resolved.executable, 'wsl.exe'); - expect(resolved.usesBashShim, isTrue); - - // And it forwards the script path as a bash path. - expect(resolved.runInShell, isFalse); - expect(resolved.arguments.first, '--exec'); - expect(resolved.arguments[1], 'bash'); - expect(resolved.arguments[2], startsWith('/mnt/')); - } finally { - await sandbox.delete(recursive: true); - } - }, - ); + // And it does not add a WSL wrapper. + expect(resolved.runInShell, isFalse); + expect(resolved.arguments, const ['api']); + } finally { + await sandbox.delete(recursive: true); + } + }); }); } diff --git a/test/core/retry_test.dart b/test/core/retry_test.dart index 76348c8..5223a0a 100644 --- a/test/core/retry_test.dart +++ b/test/core/retry_test.dart @@ -84,6 +84,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); final logMessages = []; void captureLog(AppLogRecord record) { diff --git a/test/issue_event_source/github_gosmee_test.dart b/test/issue_event_source/github_gosmee_test.dart index ead73fc..2f10267 100644 --- a/test/issue_event_source/github_gosmee_test.dart +++ b/test/issue_event_source/github_gosmee_test.dart @@ -120,6 +120,8 @@ projects: databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and receives the forwarded webhook. diff --git a/test/issue_event_source/gitlab_gosmee_test.dart b/test/issue_event_source/gitlab_gosmee_test.dart index 9aac4dc..133e601 100644 --- a/test/issue_event_source/gitlab_gosmee_test.dart +++ b/test/issue_event_source/gitlab_gosmee_test.dart @@ -134,6 +134,8 @@ projects: databasePath: p.join(sandbox.path, 'state.sqlite3'), glabCommand: glabScript.path, gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and receives the forwarded webhook. diff --git a/test/issue_event_source/gosmee_test.dart b/test/issue_event_source/gosmee_test.dart index a6dcaeb..dd5aafd 100644 --- a/test/issue_event_source/gosmee_test.dart +++ b/test/issue_event_source/gosmee_test.dart @@ -129,6 +129,8 @@ CWS_GITEA_TOKEN=test-token config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and receives the forwarded webhook. @@ -185,25 +187,11 @@ CWS_GITEA_TOKEN=test-token // And a gosmee script that starts forwarding without delivering an event. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl')); - await gosmeeScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(gosmeeLog.path)}" - -if [[ "\$1" == "--output" && "\$2" == "json" && "\$3" == "client" && "\$4" == "--new-url" ]]; then - printf '%s\n' 'https://gosmee.example.test/reconcile' - exit 0 -fi - -if [[ "\$1" == "client" ]]; then - while true; do - sleep 60 - done -fi - -echo "unexpected gosmee args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(gosmeeScript); + registerFakeGosmeeCommand( + command: gosmeeScript.path, + publicUrl: 'https://gosmee.example.test/reconcile', + gosmeeLog: gosmeeLog, + ); final issue = { 'index': 18, @@ -290,6 +278,8 @@ CWS_GITEA_TOKEN=test-token config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and enough time passes for reconciliation polling. diff --git a/test/issue_event_source/webhook_forward_test.dart b/test/issue_event_source/webhook_forward_test.dart index 42097f3..f1726ff 100644 --- a/test/issue_event_source/webhook_forward_test.dart +++ b/test/issue_event_source/webhook_forward_test.dart @@ -100,6 +100,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and receives the forwarded webhook. @@ -152,34 +154,36 @@ projects: // And a gh script that writes a webhook creation failure to stderr while forwarding. final ghScript = File(p.join(sandbox.path, 'gh')); - await ghScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - -if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then - echo "Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)" >&2 - while true; do - sleep 60 - done -fi - -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then - cat <<'JSON' -{"total_count":0,"incomplete_results":false,"items":[]} -JSON - exit 0 -fi - -echo "unexpected gh args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(ghScript); + fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async { + if (arguments.length >= 2 && + arguments[0] == 'api' && + arguments[1] == 'user') { + return ProcessResult(0, 0, '{"login":"octocat"}', ''); + } + if (arguments.length >= 4 && + arguments[0] == 'api' && + arguments[3] == 'search/issues') { + return ProcessResult( + 0, + 0, + '{"total_count":0,"incomplete_results":false,"items":[]}', + '', + ); + } + return ProcessResult( + 0, + 1, + '', + 'unexpected gh args: ${arguments.join(' ')}\n', + ); + }; + fakeStartedCommandHandlers[ghScript.path] = (arguments) async { + final process = FakeStartedExternalCommand() + ..addStderr( + 'Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)\n', + ); + return process; + }; // And an orchestrator-style config that uses gh/webhook-forward. final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); @@ -200,6 +204,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); final records = []; void captureLog(AppLogRecord record) { @@ -284,69 +290,85 @@ projects: 'user': {'login': 'reporter'}, }, ]; - await ghScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(ghLog.path)}" - -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then - count=0 - if [[ -f "${bashScriptPath(searchCountFile.path)}" ]]; then - count="\$(cat "${bashScriptPath(searchCountFile.path)}")" - fi - count=\$((count + 1)) - printf '%s' "\$count" > "${bashScriptPath(searchCountFile.path)}" - if [[ "\$count" -eq 1 ]]; then - cat <<'JSON' -{"total_count":0,"incomplete_results":false,"items":[]} -JSON - else - cat <<'JSON' -${jsonEncode({ - 'total_count': 1, - 'incomplete_results': false, - 'items': [searchItem], - })} -JSON - fi - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?per_page=100" ]]; then - cat <<'JSON' -${jsonEncode(comments)} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then - for arg in "\$@"; do - if [[ "\$arg" == body=* ]]; then - printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}" - fi - done - cat <<'JSON' -${jsonEncode({'id': 703, 'html_url': 'https://example.test/comment/703', 'body': 'posted'})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then - while true; do - sleep 60 - done -fi - -echo "unexpected gh args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(ghScript); + fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async { + await ghLog.writeAsString( + '${arguments.join(' ')}\n', + mode: FileMode.append, + ); + if (arguments.length >= 2 && + arguments[0] == 'api' && + arguments[1] == 'user') { + return ProcessResult(0, 0, '{"login":"octocat"}', ''); + } + final path = arguments.length > 3 ? arguments[3] : ''; + if (arguments.length >= 4 && + arguments[0] == 'api' && + path == 'search/issues') { + var count = 0; + if (await searchCountFile.exists()) { + count = int.parse(await searchCountFile.readAsString()); + } + count += 1; + await searchCountFile.writeAsString(count.toString()); + return ProcessResult( + 0, + 0, + jsonEncode( + count == 1 + ? { + 'total_count': 0, + 'incomplete_results': false, + 'items': [], + } + : { + 'total_count': 1, + 'incomplete_results': false, + 'items': [searchItem], + }, + ), + '', + ); + } + if (arguments.length >= 4 && + arguments[0] == 'api' && + path == 'repos/owner/sample/issues/18/comments?per_page=100') { + return ProcessResult(0, 0, jsonEncode(comments), ''); + } + if (arguments.length >= 4 && + arguments[0] == 'api' && + path == 'repos/owner/sample/issues/18/comments') { + final body = arguments + .where((argument) => argument.startsWith('body=')) + .map((argument) => argument.substring('body='.length)) + .firstOrNull; + if (body != null) { + await postedBody.writeAsString(body); + } + return ProcessResult( + 0, + 0, + jsonEncode({ + 'id': 703, + 'html_url': 'https://example.test/comment/703', + 'body': 'posted', + }), + '', + ); + } + return ProcessResult( + 0, + 1, + '', + 'unexpected gh args: ${arguments.join(' ')}\n', + ); + }; + fakeStartedCommandHandlers[ghScript.path] = (arguments) async { + await ghLog.writeAsString( + '${arguments.join(' ')}\n', + mode: FileMode.append, + ); + return FakeStartedExternalCommand(); + }; // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -383,6 +405,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the long-running app starts and enough time passes for reconciliation polling. diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart index 90ed317..aaa5524 100644 --- a/test/issue_tracker_client_test.dart +++ b/test/issue_tracker_client_test.dart @@ -146,7 +146,10 @@ void main() { postedBodyFile: postedBodyFile, viewerLogin: 'glab-user', ); - final client = IssueTrackerClient(glabCommand: glabScript.path); + final client = IssueTrackerClient( + glabCommand: glabScript.path, + commandExecutor: fakeExternalCommandExecutor(), + ); // When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment. final issues = await client.fetchUpdatedIssuesForRepos( diff --git a/test/notifier/notifications_test.dart b/test/notifier/notifications_test.dart index 0996145..c72dc55 100644 --- a/test/notifier/notifications_test.dart +++ b/test/notifier/notifications_test.dart @@ -120,6 +120,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -252,6 +254,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. diff --git a/test/support/fake_gh.dart b/test/support/fake_gh.dart index ff8e8e0..38ab70a 100644 --- a/test/support/fake_gh.dart +++ b/test/support/fake_gh.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -18,15 +19,15 @@ Future writeFakeGhScript({ String? viewerLogin, bool failViewerLookup = false, }) async { - final normalizedIssueListResponsesByRepo = + final issueLists = issueListResponsesByRepo ?? >>{'owner/sample': issueListResponse}; - final normalizedCommentResponsesByRepo = + final comments = commentResponsesByRepo ?? >>>{ 'owner/sample': commentResponses, }; - final normalizedPostCommentIssueNumbersByRepo = + final postIssueNumbers = postCommentIssueNumbersByRepo ?? >{ 'owner/sample': { @@ -35,7 +36,7 @@ Future writeFakeGhScript({ }, }; final searchItems = - normalizedIssueListResponsesByRepo.entries + issueLists.entries .expand( (entry) => entry.value.map( (issue) => { @@ -53,168 +54,28 @@ Future writeFakeGhScript({ final openSearchItems = searchItems .where((item) => item['state'] == 'open') .toList(growable: false); - final allSearchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final openSearchResponse = jsonEncode({ - 'total_count': openSearchItems.length, - 'incomplete_results': false, - 'items': openSearchItems, - }); - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln(r''' query=""''') - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' if [[ "$arg" == q=* ]]; then''') - ..writeln(r''' query="${arg#q=}"''') - ..writeln(r''' fi''') - ..writeln(r''' done''') - ..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(openSearchResponse) - ..writeln('JSON') - ..writeln(r''' else''') - ..writeln(" cat <<'JSON'") - ..writeln(allSearchResponse) - ..writeln('JSON') - ..writeln(r''' fi''') - ..writeln(' exit 0') - ..writeln('fi'); - - for (final entry in normalizedIssueListResponsesByRepo.entries) { - final repo = entry.key; - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in normalizedCommentResponsesByRepo.entries) { - final repo = repoEntry.key; - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - final repo = repoEntry.key; - for (final issueNumber in repoEntry.value) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 700, - 'html_url': 'https://example.test/comment/700', - 'body': 'posted', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - for (final repoEntry in normalizedCommentResponsesByRepo.entries) { - final repo = repoEntry.key; - for (final issueEntry in repoEntry.value.entries) { - for (final comment in issueEntry.value) { - final commentId = comment['id']; - if (commentId is! int) { - continue; - } - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/comments/$commentId/reactions" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (reactionBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == content=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#content=}" > ''' - '"${bashScriptPath(reactionBodyFile.path)}"', - ) - ..writeln(r''' fi'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'id': 990, 'content': 'eyes'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - } - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); + _registerFakeGhCommand( + command: ghScript.path, + ghLog: ghLog, + failViewerLookup: failViewerLookup, + viewerLogin: viewerLogin, + searchResponse: { + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }, + openSearchResponse: { + 'total_count': openSearchItems.length, + 'incomplete_results': false, + 'items': openSearchItems, + }, + issueLists: issueLists, + comments: comments, + postIssueNumbers: postIssueNumbers, + postedBodyFile: postedBodyFile, + reactionBodyFile: reactionBodyFile, + ); } Future writeFlakySearchGhScript({ @@ -232,68 +93,20 @@ Future writeFlakySearchGhScript({ }, ) .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln( - ' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then', - ) - ..writeln(' touch "${bashScriptPath(failureStateFile.path)}"') - ..writeln(r''' echo "error connecting to api.github.com" >&2''') - ..writeln( - r''' echo "check your internet connection or https://githubstatus.com" >&2''', - ) - ..writeln(' exit 1') - ..writeln(r''' fi''') - ..writeln(" cat <<'JSON'") - ..writeln(searchResponse) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - for (final entry in commentResponses.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); + _registerFakeGhCommand( + command: ghScript.path, + ghLog: ghLog, + searchResponse: { + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }, + flakySearchStateFile: failureStateFile, + issueLists: const {}, + comments: {'owner/sample': commentResponses}, + postIssueNumbers: const {}, + ); } Future writeWebhookForwardGhScript({ @@ -307,12 +120,6 @@ Future writeWebhookForwardGhScript({ String? viewerLogin, }) async { final issueNumber = issue['number'] as int; - final payload = { - 'action': 'created', - 'issue': issue, - 'comment': comments.last, - 'repository': {'full_name': repo}, - }; final searchItems = issueListResponse .map( (searchIssue) => { @@ -321,109 +128,31 @@ Future writeWebhookForwardGhScript({ }, ) .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(searchResponse) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(comments)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 702, - 'html_url': 'https://example.test/comment/702', - 'body': 'posted', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "webhook" && "$2" == "forward" ]]; then''') - ..writeln(r''' url=""''') - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' if [[ "$arg" == --url=* ]]; then''') - ..writeln(r''' url="${arg#--url=}"''') - ..writeln(r''' fi''') - ..writeln(r''' done''') - ..writeln(r''' if [[ -z "$url" ]]; then''') - ..writeln(r''' echo "missing --url" >&2''') - ..writeln(' exit 1') - ..writeln(r''' fi''') - ..writeln(r''' curl -sS -X POST \''') - ..writeln(r''' -H "Content-Type: application/json" \''') - ..writeln(r''' -H "X-GitHub-Event: issue_comment" \''') - ..writeln(r''' --data-binary @- "$url" <<'JSON' >/dev/null''') - ..writeln(jsonEncode(payload)) - ..writeln('JSON') - ..writeln(r''' while true; do''') - ..writeln(r''' sleep 60''') - ..writeln(r''' done''') - ..writeln('fi'); - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); + _registerFakeGhCommand( + command: ghScript.path, + ghLog: ghLog, + viewerLogin: viewerLogin, + searchResponse: { + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }, + issueLists: const {}, + comments: { + repo: {issueNumber: comments}, + }, + postIssueNumbers: { + repo: {issueNumber}, + }, + postedBodyFile: postedBodyFile, + webhookPayload: { + 'action': 'created', + 'issue': issue, + 'comment': comments.last, + 'repository': {'full_name': repo}, + }, + ); } Future writeGitHubGosmeeGhScript({ @@ -445,114 +174,234 @@ Future writeGitHubGosmeeGhScript({ }, ) .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(searchResponse) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(comments)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln('if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks" ]]; then') - ..writeln(r''' active_flag=""''') - ..writeln(r''' for ((i = 1; i <= $#; i += 1)); do''') - ..writeln(r''' if [[ "${!i}" == "active=true" ]]; then''') - ..writeln(r''' prev_index=$((i - 1))''') - ..writeln(r''' active_flag="${!prev_index}"''') - ..writeln(r''' break''') - ..writeln(r''' fi''') - ..writeln(r''' done''') - ..writeln( - r''' if [[ "$active_flag" != "-F" && "$active_flag" != "--field" ]]; then''', - ) - ..writeln( - r''' echo "expected active=true to be sent with -F/--field, got: $*" >&2''', - ) - ..writeln(r''' exit 1''') - ..writeln(r''' fi''') - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'id': webhookId})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 703, - 'html_url': 'https://example.test/comment/703', - 'body': 'posted', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks/$webhookId" ]]; then', - ) - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); + _registerFakeGhCommand( + command: ghScript.path, + ghLog: ghLog, + viewerLogin: viewerLogin, + searchResponse: { + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }, + issueLists: const {}, + comments: { + repo: {issueNumber: comments}, + }, + postIssueNumbers: { + repo: {issueNumber}, + }, + postedBodyFile: postedBodyFile, + webhookIdsByRepo: {repo: webhookId}, + ); +} + +void _registerFakeGhCommand({ + required String command, + required Map searchResponse, + required Map>> issueLists, + required Map>>> comments, + required Map> postIssueNumbers, + File? ghLog, + bool failViewerLookup = false, + String? viewerLogin, + Map? openSearchResponse, + File? postedBodyFile, + File? reactionBodyFile, + File? flakySearchStateFile, + Map? webhookPayload, + Map? webhookIdsByRepo, +}) { + if (webhookPayload != null) { + fakeStartedCommandHandlers[command] = (arguments) async { + if (ghLog != null) { + await _appendLine(ghLog, '${arguments.join(' ')}\n'); + } + final process = FakeStartedExternalCommand(); + final targetUrl = _argumentValue(arguments, '--url='); + if (targetUrl != null) { + unawaited(_postGitHubWebhook(targetUrl, webhookPayload)); + } + return process; + }; + } + + fakeCommandHandlers[command] = (arguments, {timeout}) async { + if (ghLog != null) { + await _appendLine(ghLog, '${arguments.join(' ')}\n'); + } + + ProcessResult result( + Object? stdout, { + int exitCode = 0, + String stderr = '', + }) { + final stdoutText = stdout is String ? stdout : jsonEncode(stdout); + return ProcessResult(0, exitCode, stdoutText, stderr); + } + + if (_argsMatch(arguments, ['api', 'user'])) { + if (failViewerLookup) { + return result('', exitCode: 1, stderr: 'viewer lookup failed\n'); + } + return result({'login': viewerLogin ?? 'test-user'}); + } + + if (_argsMatchAt(arguments, 0, 'api') && + _argsMatchAt(arguments, 3, 'search/issues')) { + if (flakySearchStateFile != null && + !await flakySearchStateFile.exists()) { + await flakySearchStateFile.writeAsString('failed-once'); + return result( + '', + exitCode: 1, + stderr: + 'error connecting to api.github.com\ncheck your internet connection or https://githubstatus.com\n', + ); + } + final query = arguments + .where((argument) => argument.startsWith('q=')) + .map((argument) => argument.substring(2)) + .firstOrNull; + return result( + query?.contains('state:open') ?? false + ? (openSearchResponse ?? searchResponse) + : searchResponse, + ); + } + + for (final entry in issueLists.entries) { + final path = 'repos/${entry.key}/issues?'; + if (_argsMatchAt(arguments, 0, 'api') && + arguments.length > 3 && + arguments[3].startsWith(path)) { + return result(entry.value); + } + } + + for (final repoEntry in comments.entries) { + for (final entry in repoEntry.value.entries) { + if (_argsMatchAt(arguments, 0, 'api') && + _argsMatchAt( + arguments, + 3, + 'repos/${repoEntry.key}/issues/${entry.key}/comments?per_page=100', + )) { + return result(entry.value); + } + } + } + + for (final repoEntry in postIssueNumbers.entries) { + for (final issueNumber in repoEntry.value) { + if (_argsMatchAt(arguments, 0, 'api') && + _argsMatchAt( + arguments, + 3, + 'repos/${repoEntry.key}/issues/$issueNumber/comments', + )) { + final body = _argumentValue(arguments, 'body='); + if (postedBodyFile != null && body != null) { + await postedBodyFile.writeAsString(body); + } + return result({ + 'id': 700, + 'html_url': 'https://example.test/comment/700', + 'body': 'posted', + }); + } + } + } + + for (final repoEntry in comments.entries) { + for (final issueEntry in repoEntry.value.entries) { + for (final comment in issueEntry.value) { + final commentId = comment['id']; + if (commentId is int && + _argsMatchAt(arguments, 0, 'api') && + _argsMatchAt( + arguments, + 3, + 'repos/${repoEntry.key}/issues/comments/$commentId/reactions', + )) { + final content = _argumentValue(arguments, 'content='); + if (reactionBodyFile != null && content != null) { + await reactionBodyFile.writeAsString(content); + } + return result({'id': 990, 'content': 'eyes'}); + } + } + } + } + + for (final entry in (webhookIdsByRepo ?? const {}).entries) { + final hooksPath = 'repos/${entry.key}/hooks'; + if (_argsMatchAt(arguments, 0, 'api') && + _argsMatchAt(arguments, 3, hooksPath)) { + return result({'id': entry.value}); + } + if (_argsMatchAt(arguments, 0, 'api') && + _argsMatchAt(arguments, 3, '$hooksPath/${entry.value}')) { + return result(null); + } + } + + return result( + '', + exitCode: 1, + stderr: 'unexpected gh args: ${arguments.join(' ')}\n', + ); + }; +} + +Future _postGitHubWebhook( + String targetUrl, + Map webhookPayload, +) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(targetUrl)); + request.headers.contentType = ContentType.json; + request.headers.set('X-GitHub-Event', 'issue_comment'); + request.write(jsonEncode(webhookPayload)); + await request.close(); + } finally { + client.close(force: true); + } +} + +bool _argsMatch(List arguments, List expected) => + arguments.length >= expected.length && + Iterable.generate( + expected.length, + ).every((index) => arguments[index] == expected[index]); + +bool _argsMatchAt(List arguments, int index, String expected) => + arguments.length > index && arguments[index] == expected; + +String? _argumentValue(List arguments, String prefix) { + for (final argument in arguments) { + if (argument.startsWith(prefix)) { + return argument.substring(prefix.length); + } + } + return null; +} + +Future _appendLine(File file, String line) async { + for (var attempt = 0; attempt < 200; attempt += 1) { + RandomAccessFile? log; + try { + log = file.openSync(mode: FileMode.append); + log.lockSync(FileLock.exclusive); + log.writeStringSync(line); + log.unlockSync(); + return; + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 5)); + } finally { + log?.closeSync(); + } + } + await file.writeAsString(line, mode: FileMode.append); } diff --git a/test/support/fake_glab.dart b/test/support/fake_glab.dart index c549e02..4ea0a00 100644 --- a/test/support/fake_glab.dart +++ b/test/support/fake_glab.dart @@ -15,7 +15,7 @@ Future writeFakeGlabScript({ bool failViewerLookup = false, Map? webhookIdsByRepo, }) async { - final normalizedPostCommentIssueNumbersByRepo = + final postIssueNumbers = postCommentIssueNumbersByRepo ?? >{ for (final entry in issueListResponsesByRepo.entries) @@ -24,123 +24,110 @@ Future writeFakeGlabScript({ .whereType() .toSet(), }; - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (glabLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(glabLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'username': viewerLogin ?? 'glab-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final entry in issueListResponsesByRepo.entries) { - final repo = Uri.encodeComponent(entry.key); - buffer - ..writeln( - r'''if [[ "$1" == "api" && "$4" == "projects/''' - '$repo' - r'''/issues?state=opened"* ]]; then''', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in commentResponsesByRepo.entries) { - final repo = Uri.encodeComponent(repoEntry.key); - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"projects/$repo/issues/${entry.key}/notes?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); + fakeCommandHandlers[glabScript.path] = (arguments, {timeout}) async { + if (glabLog != null) { + await glabLog.writeAsString( + '${arguments.join(' ')}\n', + mode: FileMode.append, + ); } - } - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - final repo = Uri.encodeComponent(repoEntry.key); - for (final issueNumber in repoEntry.value) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"projects/$repo/issues/$issueNumber/notes" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); + ProcessResult result( + Object? stdout, { + int exitCode = 0, + String stderr = '', + }) { + return ProcessResult( + 0, + exitCode, + stdout is String ? stdout : jsonEncode(stdout), + stderr, + ); + } + + if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) { + if (failViewerLookup) { + return result('', exitCode: 1, stderr: 'viewer lookup failed\n'); } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ + return result({'username': viewerLogin ?? 'glab-user'}); + } + + for (final entry in issueListResponsesByRepo.entries) { + final repo = Uri.encodeComponent(entry.key); + if (_matches(arguments, 0, 'api') && + arguments.length > 3 && + arguments[3].startsWith('projects/$repo/issues?state=opened')) { + return result(entry.value); + } + } + + for (final repoEntry in commentResponsesByRepo.entries) { + final repo = Uri.encodeComponent(repoEntry.key); + for (final entry in repoEntry.value.entries) { + if (_matches(arguments, 0, 'api') && + _matches( + arguments, + 3, + 'projects/$repo/issues/${entry.key}/notes?per_page=100', + )) { + return result(entry.value); + } + } + } + + for (final repoEntry in postIssueNumbers.entries) { + final repo = Uri.encodeComponent(repoEntry.key); + for (final issueNumber in repoEntry.value) { + if (_matches(arguments, 0, 'api') && + _matches( + arguments, + 3, + 'projects/$repo/issues/$issueNumber/notes', + )) { + final body = _valueArg(arguments, 'body='); + if (postedBodyFile != null && body != null) { + await postedBodyFile.writeAsString(body); + } + return result({ 'id': 901, 'body': 'posted', 'noteable_url': 'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); + }); + } + } + } + + for (final entry in (webhookIdsByRepo ?? const {}).entries) { + final repo = Uri.encodeComponent(entry.key); + if (_matches(arguments, 0, 'api') && + _matches(arguments, 2, 'POST') && + _matches(arguments, 3, 'projects/$repo/hooks')) { + return result({'id': entry.value}); + } + if (_matches(arguments, 0, 'api') && + _matches(arguments, 2, 'DELETE') && + _matches(arguments, 3, 'projects/$repo/hooks/${entry.value}')) { + return result(null); + } + } + + return result( + '', + exitCode: 1, + stderr: 'unexpected glab args: ${arguments.join(' ')}\n', + ); + }; +} + +bool _matches(List arguments, int index, String value) => + arguments.length > index && arguments[index] == value; + +String? _valueArg(List arguments, String prefix) { + for (final argument in arguments) { + if (argument.startsWith(prefix)) { + return argument.substring(prefix.length); } } - - for (final entry in (webhookIdsByRepo ?? const {}).entries) { - final repo = Uri.encodeComponent(entry.key); - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks" && "\$3" == "POST" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'id': entry.value})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi') - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks/${entry.value}" && "\$3" == "DELETE" ]]; then', - ) - ..writeln(" printf '%s\\n' 'null'") - ..writeln(' exit 0') - ..writeln('fi'); - } - - buffer - ..writeln(r'''echo "unexpected glab args: $*" >&2''') - ..writeln('exit 1'); - - await glabScript.writeAsString(buffer.toString()); - await makeScriptExecutable(glabScript); + return null; } diff --git a/test/support/fake_gosmee.dart b/test/support/fake_gosmee.dart index 5682cf3..172a316 100644 --- a/test/support/fake_gosmee.dart +++ b/test/support/fake_gosmee.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -9,55 +10,94 @@ Future writeGosmeeForwardScript({ required Map payload, File? gosmeeLog, }) async { - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (gosmeeLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(gosmeeLog.path)}"', - ); - } - - buffer - ..writeln( - r'''if [[ "$1" == "--output" && "$2" == "json" && "$3" == "client" && "$4" == "--new-url" ]]; then''', - ) - ..writeln( - r''' printf '%s\n' ''' - "'$publicUrl'", - ) - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "client" ]]; then''') - ..writeln(r''' url="$2"''') - ..writeln(r''' target="$3"''') - ..writeln( - r''' if [[ "$url" != ''' - "'$publicUrl'" - r''' ]]; then''', - ) - ..writeln(r''' echo "unexpected gosmee url: $url" >&2''') - ..writeln(' exit 1') - ..writeln(r''' fi''') - ..writeln(r''' curl -sS -X POST \''') - ..writeln(r''' -H "Content-Type: application/json" \''') - ..writeln(r''' -H "X-Gitea-Event: issue_comment" \''') - ..writeln(r''' --data-binary @- "$target" <<'JSON' >/dev/null''') - ..writeln(jsonEncode(payload)) - ..writeln('JSON') - ..writeln(r''' while true; do''') - ..writeln(r''' sleep 60''') - ..writeln(r''' done''') - ..writeln('fi'); - - buffer - ..writeln(r'''echo "unexpected gosmee args: $*" >&2''') - ..writeln('exit 1'); - - await gosmeeScript.writeAsString(buffer.toString()); - await makeScriptExecutable(gosmeeScript); + registerFakeGosmeeCommand( + command: gosmeeScript.path, + publicUrl: publicUrl, + payload: payload, + gosmeeLog: gosmeeLog, + ); +} + +void registerFakeGosmeeCommand({ + required String command, + required String publicUrl, + Map? payload, + File? gosmeeLog, + String webhookHeader = 'X-Gitea-Event', +}) { + fakeCommandHandlers[command] = (arguments, {timeout}) async { + if (gosmeeLog != null) { + await _appendLine(gosmeeLog, '${arguments.join(' ')}\n'); + } + if (_matchesNewUrl(arguments)) { + return ProcessResult(0, 0, publicUrl, ''); + } + return ProcessResult( + 0, + 1, + '', + 'unexpected gosmee args: ${arguments.join(' ')}\n', + ); + }; + + fakeStartedCommandHandlers[command] = (arguments) async { + if (gosmeeLog != null) { + await _appendLine(gosmeeLog, '${arguments.join(' ')}\n'); + } + final process = FakeStartedExternalCommand(); + if (arguments.length >= 3 && + arguments[0] == 'client' && + arguments[1] == publicUrl) { + if (payload != null) { + unawaited(_postWebhook(arguments[2], payload, webhookHeader)); + } + return process; + } + process.addStderr('unexpected gosmee args: ${arguments.join(' ')}\n'); + process.complete(1); + return process; + }; +} + +bool _matchesNewUrl(List arguments) { + return arguments.length >= 4 && + arguments[0] == '--output' && + arguments[1] == 'json' && + arguments[2] == 'client' && + arguments[3] == '--new-url'; +} + +Future _postWebhook( + String targetUrl, + Map payload, + String webhookHeader, +) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(targetUrl)); + request.headers.contentType = ContentType.json; + request.headers.set(webhookHeader, 'issue_comment'); + request.write(jsonEncode(payload)); + await request.close(); + } finally { + client.close(force: true); + } +} + +Future _appendLine(File file, String line) async { + for (var attempt = 0; attempt < 200; attempt += 1) { + RandomAccessFile? log; + try { + log = file.openSync(mode: FileMode.append); + log.lockSync(FileLock.exclusive); + log.writeStringSync(line); + log.unlockSync(); + return; + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 5)); + } finally { + log?.closeSync(); + } + } + await file.writeAsString(line, mode: FileMode.append); } diff --git a/test/support/fake_responders.dart b/test/support/fake_responders.dart index 3328898..ca08d72 100644 --- a/test/support/fake_responders.dart +++ b/test/support/fake_responders.dart @@ -1,7 +1,14 @@ import 'dart:convert'; import 'dart:io'; -import 'script_utils.dart'; +import 'package:code_work_spawner/src/config/config.dart'; +import 'package:code_work_spawner/src/core/cli_responder.dart'; +import 'package:code_work_spawner/src/issue_tracker/issue_tracker_client.dart'; + +final Map _fakeResponders = {}; + +CliResponderRunner fakeResponderRunner() => + FakeResponderRunner(Map.from(_fakeResponders)); Future writeResponderScript( File responderScript, @@ -9,33 +16,34 @@ Future writeResponderScript( String stderr = '', int exitCode = 0, }) async { - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -cat >/dev/null -${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"} -cat <<'JSON' -${jsonEncode(response)} -JSON -exit $exitCode -'''); - await makeScriptExecutable(responderScript); + _fakeResponders[responderScript.path] = _FakeResponder( + response: response, + stderr: stderr, + exitCode: exitCode, + ); +} + +Future writeCapturingResponderScript( + File responderScript, + Map response, { + File? cwdCapture, + File? stdinCapture, +}) async { + _fakeResponders[responderScript.path] = _FakeResponder( + response: response, + cwdCapture: cwdCapture, + stdinCapture: stdinCapture, + ); } Future writeJsonlResponderScript( File responderScript, Map response, ) async { - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -cat >/dev/null -cat <<'JSON' -{"type":"thread.started","thread_id":"test-thread"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}} -{"type":"turn.completed"} -JSON -'''); - await makeScriptExecutable(responderScript); + _fakeResponders[responderScript.path] = _FakeResponder( + response: response, + emitsJsonl: true, + ); } Future writeDelayedLoggingResponderScript( @@ -44,21 +52,142 @@ Future writeDelayedLoggingResponderScript( required Duration delay, required Map response, }) async { - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -timestamp_ms() { - python3 - <<'PY' -import time -print(int(time.time() * 1000)) -PY + _fakeResponders[responderScript.path] = _FakeResponder( + response: response, + eventLog: eventLog, + delay: delay, + ); } -printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}" -cat >/dev/null -sleep ${delay.inSeconds} -printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}" -cat <<'JSON' -${jsonEncode(response)} -JSON -'''); - await makeScriptExecutable(responderScript); + +class FakeResponderRunner extends CliResponderRunner { + FakeResponderRunner(this._responders); + + final Map _responders; + + @override + Future run({ + required CliResponderConfig responder, + required ProjectConfig project, + required IssueThread thread, + required String prompt, + required String workspacePath, + }) async { + final fake = _responders[responder.command]; + if (fake == null) { + throw StateError( + 'No fake responder registered for command ${responder.command}.', + ); + } + + final context = { + 'prompt': prompt, + 'repo': project.repo, + 'project_key': project.key, + 'project_path': project.path, + 'workspace_path': workspacePath, + 'issue_number': thread.issue.number.toString(), + 'thread_json': encodePrettyJson(thread.toJson()), + }; + final stdinPayload = renderTemplate(responder.stdinTemplate, context); + + await fake.run( + issueNumber: thread.issue.number, + stdinPayload: stdinPayload, + workspacePath: workspacePath, + ); + final rawStdout = fake.rawStdout; + if (fake.exitCode != 0) { + throw CliResponderExecutionException( + responderId: responder.id, + command: responder.command, + arguments: responder.args, + exitCode: fake.exitCode, + rawStdout: rawStdout, + rawStderr: fake.stderr, + ); + } + + return ResponderResult.fromJson( + fake.response, + responderId: responder.id, + rawStdout: rawStdout, + rawStderr: fake.stderr, + ); + } +} + +class _FakeResponder { + _FakeResponder({ + required this.response, + this.stderr = '', + this.exitCode = 0, + this.emitsJsonl = false, + this.eventLog, + this.delay = Duration.zero, + this.cwdCapture, + this.stdinCapture, + }); + + final Map response; + final String stderr; + final int exitCode; + final bool emitsJsonl; + final File? eventLog; + final Duration delay; + final File? cwdCapture; + final File? stdinCapture; + + String get rawStdout { + if (!emitsJsonl) { + return jsonEncode(response); + } + return [ + jsonEncode({'type': 'thread.started', 'thread_id': 'test-thread'}), + jsonEncode({'type': 'turn.started'}), + jsonEncode({ + 'type': 'item.completed', + 'item': { + 'id': 'item_0', + 'type': 'agent_message', + 'text': jsonEncode(response), + }, + }), + jsonEncode({'type': 'turn.completed'}), + '', + ].join('\n'); + } + + Future run({ + required int issueNumber, + required String stdinPayload, + required String workspacePath, + }) async { + await cwdCapture?.writeAsString(workspacePath); + await stdinCapture?.writeAsString(stdinPayload); + if (eventLog != null) { + await _appendEvent('start', issueNumber); + await Future.delayed(delay); + await _appendEvent('end', issueNumber); + } + } + + Future _appendEvent(String phase, int issueNumber) async { + final timestampMs = DateTime.now().microsecondsSinceEpoch ~/ 1000; + final line = '$phase:$issueNumber:$timestampMs\n'; + for (var attempt = 0; attempt < 200; attempt += 1) { + RandomAccessFile? log; + try { + log = eventLog!.openSync(mode: FileMode.append); + log.lockSync(FileLock.exclusive); + log.writeStringSync(line); + log.unlockSync(); + return; + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 5)); + } finally { + log?.closeSync(); + } + } + await eventLog!.writeAsString(line, mode: FileMode.append); + } } diff --git a/test/support/fake_tea.dart b/test/support/fake_tea.dart index ec384a8..aba9396 100644 --- a/test/support/fake_tea.dart +++ b/test/support/fake_tea.dart @@ -14,7 +14,7 @@ Future writeFakeTeaScript({ String? viewerLogin, bool failViewerLookup = false, }) async { - final normalizedPostCommentIssueNumbersByRepo = + final postIssueNumbers = postCommentIssueNumbersByRepo ?? >{ for (final entry in issueListResponsesByRepo.entries) @@ -23,103 +23,92 @@ Future writeFakeTeaScript({ .whereType() .toSet(), }; - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (teaLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(teaLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'tea-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final entry in issueListResponsesByRepo.entries) { - final repo = entry.key; - buffer - ..writeln( - r'''if [[ "$1" == "api" && "$4" == "repos/''' - '$repo' - r'''/issues?state=open"* ]]; then''', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in commentResponsesByRepo.entries) { - final repo = repoEntry.key; - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/${entry.key}/comments?limit=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); + fakeCommandHandlers[teaScript.path] = (arguments, {timeout}) async { + if (teaLog != null) { + await teaLog.writeAsString( + '${arguments.join(' ')}\n', + mode: FileMode.append, + ); } - } - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - final repo = repoEntry.key; - for (final issueNumber in repoEntry.value) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); + ProcessResult result( + Object? stdout, { + int exitCode = 0, + String stderr = '', + }) { + return ProcessResult( + 0, + exitCode, + stdout is String ? stdout : jsonEncode(stdout), + stderr, + ); + } + + if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) { + if (failViewerLookup) { + return result('', exitCode: 1, stderr: 'viewer lookup failed\n'); } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ + return result({'login': viewerLogin ?? 'tea-user'}); + } + + for (final entry in issueListResponsesByRepo.entries) { + if (_matches(arguments, 0, 'api') && + arguments.length > 3 && + arguments[3].startsWith('repos/${entry.key}/issues?state=open')) { + return result(entry.value); + } + } + + for (final repoEntry in commentResponsesByRepo.entries) { + for (final entry in repoEntry.value.entries) { + if (_matches(arguments, 0, 'api') && + _matches( + arguments, + 3, + 'repos/${repoEntry.key}/issues/${entry.key}/comments?limit=100', + )) { + return result(entry.value); + } + } + } + + for (final repoEntry in postIssueNumbers.entries) { + for (final issueNumber in repoEntry.value) { + if (_matches(arguments, 0, 'api') && + _matches( + arguments, + 3, + 'repos/${repoEntry.key}/issues/$issueNumber/comments', + )) { + final body = _valueArg(arguments, 'body='); + if (postedBodyFile != null && body != null) { + await postedBodyFile.writeAsString(body); + } + return result({ 'id': 701, 'url': 'https://gitea.example.test/comment/701', 'body': 'posted', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); + }); + } + } + } + + return result( + '', + exitCode: 1, + stderr: 'unexpected tea args: ${arguments.join(' ')}\n', + ); + }; +} + +bool _matches(List arguments, int index, String value) => + arguments.length > index && arguments[index] == value; + +String? _valueArg(List arguments, String prefix) { + for (final argument in arguments) { + if (argument.startsWith(prefix)) { + return argument.substring(prefix.length); } } - - buffer - ..writeln(r'''echo "unexpected tea args: $*" >&2''') - ..writeln('exit 1'); - - await teaScript.writeAsString(buffer.toString()); - await makeScriptExecutable(teaScript); + return null; } diff --git a/test/support/script_utils.dart b/test/support/script_utils.dart index 5630dd3..c2e7223 100644 --- a/test/support/script_utils.dart +++ b/test/support/script_utils.dart @@ -1,5 +1,119 @@ +import 'dart:async'; import 'dart:io'; +import 'package:code_work_spawner/src/core/process_launcher.dart'; +import 'package:mocktail/mocktail.dart'; + +typedef FakeCommandHandler = + Future Function(List arguments, {Duration? timeout}); + +typedef FakeStartedCommandHandler = + Future Function(List arguments); + +final Map fakeCommandHandlers = {}; +final Map fakeStartedCommandHandlers = {}; + +class MockExternalCommandExecutor extends Mock + implements ExternalCommandExecutor {} + +ExternalCommandExecutor fakeExternalCommandExecutor() { + _registerMocktailFallbackValues(); + final executor = MockExternalCommandExecutor(); + when( + () => executor.run( + any(), + any>(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + includeParentEnvironment: any(named: 'includeParentEnvironment'), + timeout: any(named: 'timeout'), + ), + ).thenAnswer((invocation) { + final command = invocation.positionalArguments[0] as String; + final arguments = invocation.positionalArguments[1] as List; + final timeout = invocation.namedArguments[#timeout] as Duration?; + final handler = fakeCommandHandlers[command]; + if (handler == null) { + throw StateError('No fake command registered for $command.'); + } + return handler(arguments, timeout: timeout); + }); + when( + () => executor.start( + any(), + any>(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + includeParentEnvironment: any(named: 'includeParentEnvironment'), + runInShell: any(named: 'runInShell'), + mode: any(named: 'mode'), + ), + ).thenAnswer((invocation) { + final command = invocation.positionalArguments[0] as String; + final arguments = invocation.positionalArguments[1] as List; + final handler = fakeStartedCommandHandlers[command]; + if (handler == null) { + throw StateError('No fake started command registered for $command.'); + } + return handler(arguments); + }); + return executor; +} + +class FakeStartedExternalCommand implements StartedExternalCommand { + final StreamController> _stdoutController = StreamController(); + final StreamController> _stderrController = StreamController(); + final Completer _exitCodeCompleter = Completer(); + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exitCodeCompleter.future; + + void addStdout(String text) { + if (!_stdoutController.isClosed) { + _stdoutController.add(systemEncoding.encode(text)); + } + } + + void addStderr(String text) { + if (!_stderrController.isClosed) { + _stderrController.add(systemEncoding.encode(text)); + } + } + + void complete([int exitCode = 0]) { + if (!_exitCodeCompleter.isCompleted) { + _exitCodeCompleter.complete(exitCode); + } + _stdoutController.close(); + _stderrController.close(); + } + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + complete(); + return true; + } +} + +bool _fallbackValuesRegistered = false; + +void _registerMocktailFallbackValues() { + if (_fallbackValuesRegistered) { + return; + } + _fallbackValuesRegistered = true; + registerFallbackValue([]); + registerFallbackValue({}); + registerFallbackValue(Duration.zero); + registerFallbackValue(ProcessStartMode.normal); +} + Future makeScriptExecutable(File script) async { if (Platform.isWindows) { return; @@ -15,35 +129,3 @@ Future makeScriptExecutable(File script) async { ); } } - -String bashScriptPath(String hostPath) { - if (!Platform.isWindows) { - return hostPath; - } - - final normalized = hostPath.replaceAll('\\', '/'); - final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized); - if (drivePrefixMatch == null) { - return normalized; - } - - final driveLetter = drivePrefixMatch.group(1)!.toLowerCase(); - final rest = drivePrefixMatch.group(2)!; - return '/mnt/$driveLetter/$rest'; -} - -String hostPathFromScript(String scriptPath) { - if (!Platform.isWindows) { - return scriptPath; - } - - final trimmed = scriptPath.trim(); - final mntPathMatch = RegExp(r'^/mnt/([a-zA-Z])/(.*)$').firstMatch(trimmed); - if (mntPathMatch == null) { - return trimmed; - } - - final driveLetter = mntPathMatch.group(1)!.toUpperCase(); - final rest = mntPathMatch.group(2)!.replaceAll('/', '\\'); - return '$driveLetter:\\$rest'; -} diff --git a/test/workspace/worktree_test.dart b/test/workspace/worktree_test.dart index b4ab2af..88d9b1c 100644 --- a/test/workspace/worktree_test.dart +++ b/test/workspace/worktree_test.dart @@ -76,15 +76,16 @@ void registerIssueAssistantAppRunOnceWorktreeTests() { final responderScript = File( p.join(sandbox.path, 'responder-capture.sh'), ); - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -pwd > "${bashScriptPath(cwdCapture.path)}" -cat > "${bashScriptPath(stdinCapture.path)}" -cat <<'JSON' -{"decision":"no_reply","mode":"planning","summary":"captured mention context"} -JSON -'''); - await makeScriptExecutable(responderScript); + await writeCapturingResponderScript( + responderScript, + { + 'decision': 'no_reply', + 'mode': 'planning', + 'summary': 'captured mention context', + }, + cwdCapture: cwdCapture, + stdinCapture: stdinCapture, + ); // And an orchestrator-style config pointing to the fake repo and responder. final configFile = File( @@ -110,6 +111,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -117,7 +120,7 @@ projects: await app.close(); // Then the responder runs from an ephemeral worktree instead of the source checkout. - final capturedCwd = hostPathFromScript(await cwdCapture.readAsString()); + final capturedCwd = await cwdCapture.readAsString(); expect( p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)), @@ -201,15 +204,12 @@ projects: final responderScript = File( p.join(sandbox.path, 'planning-responder.sh'), ); - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -pwd > "${bashScriptPath(cwdCapture.path)}" -cat >/dev/null -cat <<'JSON' -{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"} -JSON -'''); - await makeScriptExecutable(responderScript); + await writeCapturingResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'planning reply', + 'summary': 'used project path', + }, cwdCapture: cwdCapture); // And an orchestrator-style config pointing to the fake repo and responder. final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); @@ -234,6 +234,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. @@ -241,7 +243,7 @@ projects: await app.close(); // Then the responder runs from an ephemeral worktree instead of the source checkout. - final capturedCwd = hostPathFromScript(await cwdCapture.readAsString()); + final capturedCwd = await cwdCapture.readAsString(); expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path))); expect( p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())), @@ -331,6 +333,8 @@ projects: config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), ); // When the app processes one polling cycle. diff --git a/tool/process_mode_probe.dart b/tool/process_mode_probe.dart index 4279ac1..aa935cf 100644 --- a/tool/process_mode_probe.dart +++ b/tool/process_mode_probe.dart @@ -68,7 +68,6 @@ Future main(List arguments) async { 'executable': resolved.executable, 'arguments': resolved.arguments, 'run_in_shell': resolved.runInShell, - 'uses_bash_shim': resolved.usesBashShim, }, 'results': [], };