From 041225b2aa07c513e8e779473c4a831480190218 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 11 Apr 2026 05:35:47 +0800 Subject: [PATCH 1/2] fix: `tea` CLI call hang in windows by replaced it with direct http API called --- examples/README.md | 5 + lib/src/core/process_launcher.dart | 325 +++++++++++++- lib/src/issue_tracker/client.dart | 549 +++++++++++++++++++---- pubspec.yaml | 1 + test/core/multi_project_test.dart | 56 ++- test/core/process_launcher_test.dart | 108 +++++ test/issue_event_source/gosmee_test.dart | 228 +++------- test/issue_tracker_client_test.dart | 450 ++++++++++++++++--- test/support/fake_gitea_api.dart | 160 +++++++ test/test_support.dart | 1 + tool/process_mode_probe.dart | 292 ++++++++++++ 11 files changed, 1825 insertions(+), 350 deletions(-) create mode 100644 test/core/process_launcher_test.dart create mode 100644 test/support/fake_gitea_api.dart create mode 100644 tool/process_mode_probe.dart diff --git a/examples/README.md b/examples/README.md index c75939b..ba3c370 100644 --- a/examples/README.md +++ b/examples/README.md @@ -79,3 +79,8 @@ Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, [`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject. This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. + +## tea + windows + +The `tea` CLI has known severe issues on Windows due to lipgloss/v2 query console color. We had bypass it by read config file of `tea` directly, but only token login is supported. And need to config a default login through `tea login default `. + diff --git a/lib/src/core/process_launcher.dart b/lib/src/core/process_launcher.dart index 2b32daa..097cb1d 100644 --- a/lib/src/core/process_launcher.dart +++ b/lib/src/core/process_launcher.dart @@ -1,39 +1,92 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import '../config/app_logger.dart'; +import 'package:executable/executable.dart'; import 'package:path/path.dart' as p; -class _ResolvedCommand { - _ResolvedCommand({ +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; } -_ResolvedCommand _resolveCommand(String executable, List arguments) { - final extension = p.extension(executable).toLowerCase(); +final AppLogger _processLauncherLogger = AppLogger( + 'code_work_spawner.process_launcher', +); + +ResolvedExternalCommand resolveExternalCommandForCurrentPlatform( + String executable, + List arguments, { + bool? isWindows, +}) { + final isWindowsPlatform = isWindows ?? Platform.isWindows; + final localExecutablePath = _resolveLocalExecutablePath(executable); + final resolvedExecutablePath = + localExecutablePath ?? _resolveExecutablePathOnPath(executable); + final effectiveExecutable = resolvedExecutablePath ?? executable; + final extension = p.extension(effectiveExecutable).toLowerCase(); final shouldUseBash = extension.isEmpty || extension == '.sh'; - if (Platform.isWindows && shouldUseBash && File(executable).existsSync()) { - final bashScriptPath = _toBashPath(executable); - return _ResolvedCommand( + 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 _ResolvedCommand( - executable: executable, + 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), ); } +bool _requiresWindowsShell(String extension) { + return extension == '.bat' || extension == '.cmd'; +} + +String? _resolveLocalExecutablePath(String executable) { + final localFile = File(executable); + if (!localFile.existsSync()) { + return null; + } + return localFile.path; +} + +String? _resolveExecutablePathOnPath(String executable) { + if (_looksLikePathReference(executable)) { + return null; + } + return Executable(executable).findSync(); +} + +bool _looksLikePathReference(String executable) { + return executable.contains(r'\') || + executable.contains('/') || + executable.startsWith('.'); +} + String _toBashPath(String windowsPath) { final normalized = windowsPath.replaceAll('\\', '/'); final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized); @@ -52,20 +105,251 @@ Future runExternalCommand( String? workingDirectory, Map? environment, bool includeParentEnvironment = true, -}) { - final resolved = _resolveCommand(executable, arguments); + Duration? timeout, +}) async { + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + arguments, + ); final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( environment: environment, includeParentEnvironment: includeParentEnvironment, usesBashShim: resolved.usesBashShim, ); - return Process.run( - resolved.executable, - resolved.arguments, - workingDirectory: workingDirectory, - environment: effectiveEnvironment, - includeParentEnvironment: includeParentEnvironment, + final mode = Platform.isWindows && !resolved.usesBashShim + ? 'windows-wrapper' + : 'process-run'; + _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( + executable: resolved.executable, + arguments: resolved.arguments, + workingDirectory: workingDirectory, + environment: effectiveEnvironment, + includeParentEnvironment: includeParentEnvironment, + 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} ' + 'duration_ms=${stopwatch.elapsedMilliseconds} ' + 'stdout_len=${result.stdout.toString().length} ' + 'stderr_len=${result.stderr.toString().length}', + ); + return result; + } on TimeoutException { + stopwatch.stop(); + _processLauncherLogger.debug( + 'timeout mode=$mode duration_ms=${stopwatch.elapsedMilliseconds}', + ); + rethrow; + } +} + +Future _runExternalCommandViaWindowsWrapper({ + required String executable, + required List arguments, + required String? workingDirectory, + required Map? environment, + required bool includeParentEnvironment, + required Duration? timeout, +}) async { + const timeoutExitCode = 124; + final sandbox = await Directory.systemTemp.createTemp( + 'cws-process-launcher-', + ); + 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'); + + 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); + } +} + +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(' '); +} + +String _summarizeLauncherArgument(String value) { + if (value.startsWith('body=')) { + return 'body='; + } + if (value.length <= 120) { + return value; + } + return '${value.substring(0, 117)}...'; } Future startExternalCommand( @@ -77,7 +361,10 @@ Future startExternalCommand( bool runInShell = false, ProcessStartMode mode = ProcessStartMode.normal, }) { - final resolved = _resolveCommand(executable, arguments); + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + arguments, + ); final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( environment: environment, includeParentEnvironment: includeParentEnvironment, @@ -89,7 +376,7 @@ Future startExternalCommand( workingDirectory: workingDirectory, environment: effectiveEnvironment, includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, + runInShell: runInShell || resolved.runInShell, mode: mode, ); } diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 7f12b3f..887827e 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:github/github.dart'; import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; import '../config/app_environment.dart'; +import '../config/app_logger.dart'; import '../config/config_schema.dart'; import '../core/process_launcher.dart'; import 'models.dart'; @@ -15,12 +18,17 @@ class IssueTrackerClient { this.glabCommand = 'glab', this.teaCommand = 'tea', this.opCommand = 'op', - }); + Duration? commandTimeout, + AppLogger? logger, + }) : commandTimeout = commandTimeout ?? const Duration(seconds: 15), + _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'); final String ghCommand; final String glabCommand; final String teaCommand; final String opCommand; + final Duration commandTimeout; + final AppLogger _logger; final Map> _authenticatedLoginFutures = >{}; final Map> _openProjectProjectFutures = @@ -81,18 +89,14 @@ class IssueTrackerClient { {'per_page': '100'}, ), ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath( + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'GET', + path: _buildApiPath( issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'limit': '100'}, ), - '-r', - issue.requiredRepoSlug.fullName, - ]), + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/work_packages/${issue.number}/activities', @@ -157,20 +161,15 @@ class IssueTrackerClient { '-f', 'body=$body', ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'POST', - _buildApiPath(parseRepositorySlug(trackerProject), [ + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'POST', + path: _buildApiPath(parseRepositorySlug(trackerProject), [ 'issues', '$issueNumber', 'comments', ]), - '-f', - 'body=$body', - '-r', - trackerProject, - ]), + body: {'body': body}, + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'POST', path: '/api/v3/work_packages/$issueNumber/activities', @@ -241,7 +240,7 @@ class IssueTrackerClient { return switch (provider) { IssueTrackerProvider.github => 'gh', IssueTrackerProvider.gitlab => 'glab', - IssueTrackerProvider.gitea => 'tea', + IssueTrackerProvider.gitea => 'gitea', IssueTrackerProvider.openproject => opCommand, }; } @@ -250,43 +249,23 @@ class IssueTrackerClient { required RepositorySlug repo, required String url, }) async { - final result = await _runCommand(teaCommand, [ - 'webhooks', - 'create', - '--type', - 'gitea', - '--events', - 'issues,issue_comment', - '--active', - '-o', - 'json', - '-r', - repo.fullName, - url, - ]); - final stdoutText = result.stdout.toString().trim(); - if (stdoutText.isEmpty) { - throw FormatException( - 'tea webhooks create returned an empty response for repo ' - '${repo.fullName}.', - ); - } - - try { - final json = jsonDecode(stdoutText); - if (json is Map) { - return readIntField(json, 'id'); - } - } on FormatException { - final webhookId = _extractWebhookIdFromTeaOutput(stdoutText); - if (webhookId != null) { - return webhookId; - } + final json = await _runGiteaJson( + method: 'POST', + path: _buildApiPath(repo, ['hooks']), + body: { + 'type': 'gitea', + 'config': {'url': url, 'content_type': 'json'}, + 'events': ['issues', 'issue_comment'], + 'active': true, + }, + ); + if (json is Map) { + return readIntField(json, 'id'); } throw FormatException( - 'tea webhooks create returned an unexpected response for repo ' - '${repo.fullName}. stdout=${_summarizeOutput(stdoutText)}', + 'Gitea API webhook create returned an unexpected response for repo ' + '${repo.fullName}.', ); } @@ -362,14 +341,10 @@ class IssueTrackerClient { required RepositorySlug repo, required int webhookId, }) async { - await _runTeaJson([ - 'webhooks', - 'delete', - '--confirm', - '-r', - repo.fullName, - '$webhookId', - ]); + await _runGiteaJson( + method: 'DELETE', + path: _buildApiPath(repo, ['hooks', '$webhookId']), + ); } Future deleteGitLabWebhook({ @@ -404,6 +379,14 @@ class IssueTrackerClient { final issues = []; for (var page = 1; true; page += 1) { + _traceRequest( + provider: IssueTrackerProvider.github, + requestKind: 'search-issues', + trackerProject: repoList.map((repo) => repo.fullName).join(','), + page: page, + since: since, + requestPath: 'search/issues', + ); final json = await _runGhJson([ 'api', '-X', @@ -453,6 +436,14 @@ class IssueTrackerClient { 'page': '$page', if (since != null) 'updated_after': since.toUtc().toIso8601String(), }; + _traceRequest( + provider: IssueTrackerProvider.gitlab, + requestKind: 'list-issues', + trackerProject: trackerProject, + page: page, + since: since, + requestPath: _buildGitLabApiPath(trackerProject, ['issues'], query), + ); final json = await _runGlabJson([ 'api', '-X', @@ -490,23 +481,25 @@ class IssueTrackerClient { 'page': '$page', if (since != null) 'since': since.toUtc().toIso8601String(), }; - final json = await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath(repo, ['issues'], query), - '-r', - repo.fullName, - ]); + final requestPath = _buildApiPath(repo, ['issues'], query); + _traceRequest( + provider: IssueTrackerProvider.gitea, + requestKind: 'list-issues', + trackerProject: repo.fullName, + page: page, + since: since, + requestPath: requestPath, + ); + final json = await _runGiteaJson(method: 'GET', path: requestPath); if (json is Map) { throw FormatException( - 'tea api returned an error while listing Gitea issues for ' + 'Gitea API returned an error while listing issues for ' '${repo.fullName}: ${_describeJsonObject(json)}', ); } if (json != null && json is! List) { throw FormatException( - 'tea api returned ${json.runtimeType} while listing Gitea issues ' + 'Gitea API returned ${json.runtimeType} while listing issues ' 'for ${repo.fullName}; expected a JSON array.', ); } @@ -579,7 +572,10 @@ class IssueTrackerClient { final json = switch (provider) { IssueTrackerProvider.github => await _runGhJson(['api', 'user']), IssueTrackerProvider.gitlab => await _runGlabJson(['api', 'user']), - IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']), + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'GET', + path: '/user', + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/users/me', @@ -604,8 +600,102 @@ class IssueTrackerClient { return _runJson(glabCommand, arguments); } - Future _runTeaJson(List arguments) { - return _runJson(teaCommand, arguments); + Future _runGiteaJson({ + required String method, + required String path, + Map? queryParameters, + Object? body, + }) async { + final config = await _loadGiteaConfig(); + final client = HttpClient(); + if (config.insecure) { + client.badCertificateCallback = (_, _, _) => true; + } + final uri = _buildGiteaUri( + config.host, + path, + queryParameters: queryParameters, + ); + final stopwatch = Stopwatch()..start(); + _logger.debug( + 'gitea request start timeout_ms=${commandTimeout.inMilliseconds} ' + 'method=$method uri=$uri', + ); + + try { + final result = + await (() async { + final request = await client.openUrl(method, uri); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set( + HttpHeaders.authorizationHeader, + 'token ${config.token}', + ); + if (body != null) { + request.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json', + ); + request.write(jsonEncode(body)); + } + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + return (response: response, body: responseBody); + })().timeout( + commandTimeout, + onTimeout: () { + throw TimeoutException( + 'Gitea API request timed out after ${commandTimeout.inMilliseconds}ms.', + commandTimeout, + ); + }, + ); + + stopwatch.stop(); + _logger.debug( + 'gitea request finished duration_ms=${stopwatch.elapsedMilliseconds} ' + 'status_code=${result.response.statusCode} method=$method uri=$uri', + ); + final trimmed = result.body.trim(); + final parsedJson = trimmed.isEmpty ? null : jsonDecode(trimmed); + if (result.response.statusCode < 200 || + result.response.statusCode >= 300) { + if (parsedJson is Map) { + throw FormatException( + 'Gitea API request failed ' + '(${result.response.statusCode} ${result.response.reasonPhrase}): ' + '${_describeJsonObject(parsedJson)}', + ); + } + throw HttpException( + 'Gitea API request failed ' + '(${result.response.statusCode} ${result.response.reasonPhrase}): ' + '${_summarizeOutput(result.body)}', + uri: uri, + ); + } + return parsedJson; + } on TimeoutException catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'gitea request timeout duration_ms=${stopwatch.elapsedMilliseconds} ' + 'timeout_ms=${commandTimeout.inMilliseconds} method=$method uri=$uri', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'gitea request failed duration_ms=${stopwatch.elapsedMilliseconds} ' + 'method=$method uri=$uri', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } finally { + client.close(force: true); + } } Future _runOpenProjectJson({ @@ -659,12 +749,31 @@ class IssueTrackerClient { final result = await _runCommand(command, arguments); final stdoutText = result.stdout.toString().trim(); if (stdoutText.isEmpty) { + _traceCommandResult( + command: command, + arguments: arguments, + state: 'empty-stdout', + ); return null; } try { - return jsonDecode(stdoutText); + final json = jsonDecode(stdoutText); + _traceCommandResult( + command: command, + arguments: arguments, + state: 'json-parsed', + detail: _describeJsonPayload(json), + ); + return json; } on FormatException catch (error) { + _traceCommandResult( + command: command, + arguments: arguments, + state: 'json-parse-failed', + detail: + '${error.message} stdout=${_summarizeOutput(stdoutText, maxLength: 120)}', + ); throw FormatException( 'Failed to parse JSON from `$command ${arguments.join(' ')}`: ' '${error.message}. stdout=${_summarizeOutput(stdoutText)}', @@ -676,7 +785,41 @@ class IssueTrackerClient { String command, List arguments, ) async { - final result = await runExternalCommand(command, arguments); + final renderedCommand = _renderCommand(command, arguments); + final stopwatch = Stopwatch()..start(); + _logger.debug( + 'command start timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', + ); + late final ProcessResult result; + try { + result = await runExternalCommand( + command, + arguments, + timeout: commandTimeout, + ); + } on TimeoutException catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'command timeout duration_ms=${stopwatch.elapsedMilliseconds} ' + 'timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'command failed duration_ms=${stopwatch.elapsedMilliseconds} command=$renderedCommand', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + stopwatch.stop(); + _logger.debug( + 'command finished duration_ms=${stopwatch.elapsedMilliseconds} ' + 'exit_code=${result.exitCode} command=$renderedCommand', + ); if (result.exitCode != 0) { throw ProcessException( command, @@ -688,12 +831,19 @@ class IssueTrackerClient { return result; } - int? _extractWebhookIdFromTeaOutput(String stdoutText) { - final match = RegExp( - r'Webhook created successfully \(ID:\s*(\d+)\)', - caseSensitive: false, - ).firstMatch(stdoutText); - return match == null ? null : int.parse(match.group(1)!); + String _renderCommand(String command, List arguments) { + final parts = [command, ...arguments]; + return parts.map(_shellQuote).join(' '); + } + + String _shellQuote(String value) { + if (value.isEmpty) { + return '""'; + } + if (!value.contains(RegExp(r'[\s"]'))) { + return value; + } + return '"${value.replaceAll('"', r'\"')}"'; } String _describeJsonObject(Map json) { @@ -722,6 +872,47 @@ class IssueTrackerClient { return '${singleLine.substring(0, maxLength)}...'; } + String _describeJsonPayload(Object? json) { + return switch (json) { + List list => 'type=list length=${list.length}', + Map map => + 'type=object keys=${map.keys.take(6).join(",")}', + null => 'type=null', + _ => 'type=${json.runtimeType}', + }; + } + + void _traceRequest({ + required IssueTrackerProvider provider, + required String requestKind, + required String trackerProject, + required int page, + required DateTime? since, + required String requestPath, + }) { + _logger.debug( + 'trace request provider=${provider.name} kind=$requestKind ' + 'tracker_project=$trackerProject page=$page ' + 'since=${since?.toUtc().toIso8601String() ?? "(none)"} ' + 'path=$requestPath', + ); + } + + void _traceCommandResult({ + required String command, + required List arguments, + required String state, + String? detail, + }) { + final parts = [ + 'trace command_result state=$state', + 'command=$command', + 'args=${arguments.map(_shellQuote).join(" ")}', + if (detail != null && detail.isNotEmpty) 'detail=$detail', + ]; + _logger.debug(parts.join(' ')); + } + String _buildApiPath( RepositorySlug repo, List tailSegments, [ @@ -761,6 +952,186 @@ class IssueTrackerClient { ); } + Uri _buildGiteaUri( + Uri host, + String path, { + Map? queryParameters, + }) { + final relativeUri = Uri.parse(path); + final normalizedPath = relativeUri.path.startsWith('/') + ? relativeUri.path + : '/${relativeUri.path}'; + final effectiveQueryParameters = + queryParameters ?? + (relativeUri.hasQuery ? relativeUri.queryParameters : null); + return host.replace( + path: p.posix.normalize( + '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}/api/v1$normalizedPath', + ), + queryParameters: + effectiveQueryParameters == null || effectiveQueryParameters.isEmpty + ? null + : effectiveQueryParameters, + ); + } + + Future<_GiteaConfig> _loadGiteaConfig() async { + final environment = AppEnvironment.variables; + final host = + environment['CWS_GITEA_HOST']?.trim() ?? + environment['GITEA_HOST']?.trim(); + final token = + environment['CWS_GITEA_TOKEN']?.trim() ?? + environment['GITEA_TOKEN']?.trim(); + final insecureValue = + environment['CWS_GITEA_INSECURE']?.trim() ?? + environment['GITEA_INSECURE']?.trim(); + if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { + return _GiteaConfig( + host: Uri.parse(host), + token: token, + insecure: _parseBooleanFlag(insecureValue), + ); + } + + final loginName = + environment['CWS_GITEA_LOGIN']?.trim() ?? + environment['GITEA_LOGIN']?.trim() ?? + environment['TEA_LOGIN']?.trim(); + final configPath = _teaConfigPath(environment); + final file = File(configPath); + if (!await file.exists()) { + throw const FileSystemException( + 'Gitea config not found. Set CWS_GITEA_HOST/CWS_GITEA_TOKEN or configure tea first.', + ); + } + + final document = loadYaml(await file.readAsString()); + final logins = + (document is YamlMap ? document['logins'] : null) as YamlList?; + if (logins == null || logins.isEmpty) { + throw FileSystemException( + 'No tea logins found in Gitea config at $configPath.', + ); + } + + final loginMaps = logins + .whereType() + .map((entry) => Map.from(entry)) + .toList(growable: false); + final selectedLogin = _selectTeaLogin(loginMaps, loginName); + final loginHost = selectedLogin['url']?.toString().trim(); + final loginToken = selectedLogin['token']?.toString().trim(); + if (loginHost == null || + loginHost.isEmpty || + loginToken == null || + loginToken.isEmpty) { + throw FileSystemException( + 'Selected tea login in $configPath is missing url or token.', + ); + } + + return _GiteaConfig( + host: Uri.parse(loginHost), + token: loginToken, + insecure: _parseBooleanFlag(selectedLogin['insecure']?.toString()), + ); + } + + Map _selectTeaLogin( + List> logins, + String? requestedLoginName, + ) { + if (requestedLoginName != null && requestedLoginName.isNotEmpty) { + final matchedLogin = logins + .where((login) { + final name = login['name']?.toString().trim(); + return name != null && + name.toLowerCase() == requestedLoginName.toLowerCase(); + }) + .toList(growable: false); + if (matchedLogin.length == 1) { + return matchedLogin.single; + } + throw FileSystemException( + 'tea login "$requestedLoginName" was not found or was ambiguous.', + ); + } + + final defaultLogins = logins + .where((login) { + final value = login['default']; + return value == true || value?.toString().toLowerCase() == 'true'; + }) + .toList(growable: false); + if (defaultLogins.length == 1) { + return defaultLogins.single; + } + if (logins.length == 1) { + return logins.single; + } + throw const FileSystemException( + 'Multiple tea logins found. Set CWS_GITEA_LOGIN, GITEA_LOGIN, or TEA_LOGIN to choose one.', + ); + } + + String _teaConfigPath(Map environment) { + final explicitPath = + environment['CWS_GITEA_CONFIG_PATH']?.trim() ?? + environment['TEA_CONFIG_PATH']?.trim(); + if (explicitPath != null && explicitPath.isNotEmpty) { + return explicitPath; + } + + final candidates = []; + final localAppData = environment['LOCALAPPDATA']?.trim(); + if (localAppData != null && localAppData.isNotEmpty) { + candidates.add(p.join(localAppData, 'tea', 'config.yml')); + } + final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); + if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { + candidates.add(p.join(xdgConfigHome, 'tea', 'config.yml')); + } + final userProfile = environment['USERPROFILE']?.trim(); + if (userProfile != null && userProfile.isNotEmpty) { + candidates.add( + p.join(userProfile, 'AppData', 'Local', 'tea', 'config.yml'), + ); + candidates.add(p.join(userProfile, '.tea', 'tea.yml')); + } + final home = environment['HOME']?.trim(); + if (home != null && home.isNotEmpty) { + candidates.add(p.join(home, '.config', 'tea', 'config.yml')); + candidates.add(p.join(home, '.tea', 'tea.yml')); + } + if (candidates.isEmpty) { + throw const FileSystemException( + 'HOME is not set and LOCALAPPDATA/XDG_CONFIG_HOME/USERPROFILE are unset. Cannot locate tea config.', + ); + } + + for (final candidate in candidates) { + if (File(candidate).existsSync()) { + return candidate; + } + } + return candidates.first; + } + + bool _parseBooleanFlag(String? value) { + if (value == null) { + return false; + } + switch (value.trim().toLowerCase()) { + case '1': + case 'true': + case 'yes': + case 'on': + return true; + } + return false; + } + Future<_OpenProjectConfig> _loadOpenProjectConfig() async { final environment = AppEnvironment.variables; final host = environment['OP_CLI_HOST']?.trim(); @@ -864,6 +1235,18 @@ class _OpenProjectConfig { final String token; } +class _GiteaConfig { + const _GiteaConfig({ + required this.host, + required this.token, + required this.insecure, + }); + + final Uri host; + final String token; + final bool insecure; +} + class _OpenProjectProjectRef { const _OpenProjectProjectRef({required this.id, required this.name}); diff --git a/pubspec.yaml b/pubspec.yaml index b64e2c9..f27152d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: git: ^2.3.2 nyxx: ^6.8.1 dart_eval: ^0.8.4 + executable: ^1.4.1 dev_dependencies: build_runner: ^2.6.0 diff --git a/test/core/multi_project_test.dart b/test/core/multi_project_test.dart index b2ca8ea..aac6b81 100644 --- a/test/core/multi_project_test.dart +++ b/test/core/multi_project_test.dart @@ -234,24 +234,21 @@ projects: await initGitRepo(repoDir); // And Gitea issue data containing a comment with an explicit assistant mention. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - await writeFakeTeaScript( - teaScript: teaScript, + final giteaServer = await FakeGiteaApiServer.start( issueListResponsesByRepo: { 'owner/sample': [ { - 'index': 12, + 'number': 12, 'title': 'Need architecture help', 'body': 'Please review the API layering.', 'state': 'open', - 'url': 'https://gitea.example.test/owner/sample/issues/12', + 'html_url': 'https://gitea.example.test/owner/sample/issues/12', 'updated_at': '2026-04-04T12:00:00Z', - 'poster': {'login': 'reporter'}, + 'user': {'login': 'reporter'}, 'labels': [ {'name': 'help'}, ], + 'pull_request': null, }, ], }, @@ -261,22 +258,28 @@ projects: { 'id': 50, 'body': '@helper can you plan the architecture?', - 'url': + 'html_url': 'https://gitea.example.test/owner/sample/issues/12#issuecomment-50', 'created_at': '2026-04-04T12:00:00Z', 'updated_at': '2026-04-04T12:00:00Z', - 'poster': {'login': 'reporter'}, + 'user': {'login': 'reporter'}, }, ], }, }, - postCommentIssueNumbersByRepo: { - 'owner/sample': {12}, + postCommentResponsesByRepo: { + 'owner/sample': { + 12: { + 'id': 701, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-701', + }, + }, }, - teaLog: teaLog, - postedBodyFile: postedBody, viewerLogin: 'tea-octocat', ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -307,37 +310,44 @@ projects: repo: owner/sample path: ${repoDir.path} defaultBranch: main +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), - teaCommand: teaScript.path, ); // When the app processes one polling cycle. await app.runOnce(); await app.close(); - // Then it reads issue comments and posts exactly one reply through tea for that issue. - final logLines = await teaLog.readAsLines(); + // Then it reads issue comments and posts exactly one reply through the Gitea API for that issue. expect( - logLines + giteaServer.requestPaths .where( - (line) => line.contains('repos/owner/sample/issues?state=open'), + (path) => path.contains('/api/v1/repos/owner/sample/issues?'), ) .length, 1, ); expect( - logLines + giteaServer.requestPaths .where( - (line) => line.contains('repos/owner/sample/issues/12/comments'), + (path) => path.contains( + '/api/v1/repos/owner/sample/issues/12/comments', + ), ) .length, 2, ); - final postedComment = await postedBody.readAsString(); - expect(postedComment, contains('via `tea`')); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; + expect(postedComment, contains('via `gitea`')); expect(postedComment, contains('@tea-octocat')); expect( postedComment, diff --git a/test/core/process_launcher_test.dart b/test/core/process_launcher_test.dart new file mode 100644 index 0000000..a676d9d --- /dev/null +++ b/test/core/process_launcher_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:code_work_spawner/src/core/process_launcher.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Process launcher command resolution + /// + /// As a maintainer running the app on Windows + /// I want native executables to avoid unnecessary shell wrappers + /// So that tracker CLIs do not hang before returning their output + /// ``` + group('resolveExternalCommandForCurrentPlatform', () { + /// ```gherkin + /// Scenario: Execute Windows native executables directly + /// Given a Windows executable path for a native CLI + /// When the process launcher resolves the command + /// Then it keeps the original executable path + /// And it does not force the command to run in a shell + /// ``` + test('Execute Windows native executables directly', () { + // Given a Windows executable path for a native CLI. + const executable = r'C:\Users\bensung\scoop\shims\tea.exe'; + + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + const ['--version'], + isWindows: true, + ); + + // Then it keeps the original executable path. + expect(resolved.executable, executable); + + // And it does not force the command to run in a shell. + expect(resolved.runInShell, isFalse); + expect(resolved.usesBashShim, isFalse); + }); + + /// ```gherkin + /// Scenario: Keep Windows batch files on the shell path + /// Given a Windows command script path + /// When the process launcher resolves the command + /// Then it leaves the script path unchanged + /// And it marks the command to run in a shell + /// ``` + test('Keep Windows batch files on the shell path', () { + // Given a Windows command script path. + const executable = r'C:\tools\helper.cmd'; + + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + const ['sync'], + isWindows: true, + ); + + // Then it leaves the script path unchanged. + expect(resolved.executable, executable); + + // 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 + /// 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 + /// ``` + 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-', + ); + 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 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); + } + }, + ); + }); +} diff --git a/test/issue_event_source/gosmee_test.dart b/test/issue_event_source/gosmee_test.dart index a082997..a6dcaeb 100644 --- a/test/issue_event_source/gosmee_test.dart +++ b/test/issue_event_source/gosmee_test.dart @@ -21,12 +21,12 @@ void registerIssueAssistantAppRunGosmeeTests() { /// Scenario: Reply to a mention received through tea/gosmee /// Given a temporary project checkout that can be used as the local repo path /// And a gosmee script that forwards one Gitea issue_comment webhook event - /// And a tea script that can manage webhooks and post issue comments + /// And a Gitea API fixture that can manage webhooks and post issue comments /// And a responder that emits a planning reply /// And an orchestrator-style config that uses tea/gosmee /// When the long-running app starts and receives the forwarded webhook /// Then it reads issue comments and posts exactly one reply for that issue - /// And the tea webhook lifecycle commands are invoked for the Gitea project + /// And the Gitea webhook lifecycle API is invoked for the project /// ``` test('Reply to a mention received through tea/gosmee', () async { // Given a temporary project checkout that can be used as the local repo path. @@ -70,62 +70,26 @@ void registerIssueAssistantAppRunGosmeeTests() { gosmeeLog: gosmeeLog, ); - // And a tea script that can manage webhooks and post issue comments. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}" - -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'tea-octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then - cat <<'JSON' -[] -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?limit=100" ]]; then - cat <<'JSON' -${jsonEncode(comments)} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then - for arg in "\$@"; do - if [[ "\$arg" == body=* ]]; then - printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}" - fi - done - cat <<'JSON' -${jsonEncode({'id': 801, 'url': 'https://gitea.example.test/comment/801', 'body': 'posted'})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - cat <<'JSON' -${jsonEncode({'id': 81})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); + // And a Gitea API fixture for issue reads and replies. + final giteaServer = await FakeGiteaApiServer.start( + issueListResponsesByRepo: { + 'owner/sample': const >[], + }, + commentResponsesByRepo: { + 'owner/sample': {12: comments}, + }, + postCommentResponsesByRepo: { + 'owner/sample': { + 12: { + 'id': 801, + 'body': 'posted', + 'html_url': 'https://gitea.example.test/comment/801', + }, + }, + }, + viewerLogin: 'tea-octocat', + ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -156,18 +120,21 @@ projects: type: tea/gosmee repo: owner/sample path: ${repoDir.path} +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, - teaCommand: teaScript.path, ); // When the long-running app starts and receives the forwarded webhook. final runFuture = app.run(); for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { + if (giteaServer.postedBodies.isNotEmpty) { break; } await Future.delayed(const Duration(milliseconds: 100)); @@ -176,38 +143,31 @@ projects: await runFuture; // Then it reads issue comments and posts exactly one reply for that issue. - expect(await postedBody.exists(), isTrue); - final postedComment = await postedBody.readAsString(); + expect(giteaServer.postedBodies, hasLength(1)); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; expect( postedComment, contains('Plan:\n- consume gosmee issue_comment events'), ); expect(postedComment, contains('@tea-octocat')); - // And the tea webhook lifecycle commands are invoked for the Gitea project. - final teaLogLines = await teaLog.readAsLines(); + // And the Gitea webhook lifecycle API is invoked for the project. + expect(giteaServer.createdWebhooks, hasLength(1)); expect( - teaLogLines.any( - (line) => line.contains( - 'webhooks create --type gitea --events issues,issue_comment --active -o json -r owner/sample https://gosmee.example.test/sample', - ), - ), - isTrue, - ); - expect( - teaLogLines.any( - (line) => - line.contains('webhooks delete --confirm -r owner/sample 81'), - ), - isTrue, + (giteaServer.createdWebhooks.single['config'] as Map)['url'], + 'https://gosmee.example.test/sample', ); + expect(giteaServer.deletedWebhookIds, contains(81)); }); /// ```gherkin /// Scenario: Reconcile a missed Gitea gosmee event through polling /// Given a temporary project checkout that can be used as the local repo path /// And a gosmee script that starts forwarding without delivering an event - /// And a tea script that returns a new issue only on a later reconciliation poll + /// And a Gitea API fixture that returns a new issue only on a later reconciliation poll /// And a responder that emits a planning reply /// And an orchestrator-style config that uses tea/gosmee with continuous reconciliation /// When the long-running app starts and enough time passes for reconciliation polling @@ -245,11 +205,6 @@ exit 1 '''); await makeScriptExecutable(gosmeeScript); - // And a tea script that returns a new issue only on a later reconciliation poll. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - final issueListCount = File(p.join(sandbox.path, 'issue-list-count')); final issue = { 'index': 18, 'title': 'Need reconciliation after a missed webhook', @@ -271,70 +226,30 @@ exit 1 'poster': {'login': 'reporter'}, }, ]; - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}" -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'tea-octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then - count=0 - if [[ -f "${bashScriptPath(issueListCount.path)}" ]]; then - count="\$(cat "${bashScriptPath(issueListCount.path)}")" - fi - count=\$((count + 1)) - printf '%s' "\$count" > "${bashScriptPath(issueListCount.path)}" - if [[ "\$count" -eq 1 ]]; then - cat <<'JSON' -[] -JSON - else - cat <<'JSON' -${jsonEncode([issue])} -JSON - fi - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?limit=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': 802, 'url': 'https://gitea.example.test/comment/802', 'body': 'posted'})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - cat <<'JSON' -${jsonEncode({'id': 82})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); + // And a Gitea API fixture that returns a new issue only on a later reconciliation poll. + final giteaServer = await FakeGiteaApiServer.start( + issueListResponseSequenceByRepo: { + 'owner/sample': [ + const >[], + [issue], + ], + }, + commentResponsesByRepo: { + 'owner/sample': {18: comments}, + }, + postCommentResponsesByRepo: { + 'owner/sample': { + 18: { + 'id': 802, + 'body': 'posted', + 'html_url': 'https://gitea.example.test/comment/802', + }, + }, + }, + viewerLogin: 'tea-octocat', + ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -366,18 +281,21 @@ projects: reconcileInterval: 1s repo: owner/sample path: ${repoDir.path} +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, - teaCommand: teaScript.path, ); // When the long-running app starts and enough time passes for reconciliation polling. final runFuture = app.run(); for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { + if (giteaServer.postedBodies.isNotEmpty) { break; } await Future.delayed(const Duration(milliseconds: 100)); @@ -386,21 +304,19 @@ projects: await runFuture; // Then it posts exactly one reply for the issue discovered by polling. - expect(await postedBody.exists(), isTrue); - final postedComment = await postedBody.readAsString(); + expect(giteaServer.postedBodies, hasLength(1)); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; expect( postedComment, contains('Plan:\n- reconcile missed gosmee events through polling'), ); // And the app performs more than one Gitea issue listing request while running. - final teaLogLines = await teaLog.readAsLines(); expect( - teaLogLines - .where( - (line) => line.contains('repos/owner/sample/issues?state=open'), - ) - .length, + giteaServer.issueListRequestCount('owner/sample'), greaterThanOrEqualTo(2), ); final gosmeeLogLines = await gosmeeLog.readAsLines(); diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart index a63d294..90ed317 100644 --- a/test/issue_tracker_client_test.dart +++ b/test/issue_tracker_client_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; @@ -189,95 +191,405 @@ void main() { /// Feature: Gitea tracker client integration /// /// As a maintainer debugging Gitea tracker failures - /// I want tea-specific API and webhook errors to preserve the original cause - /// So that misconfigured repos and tea output changes are easy to trace + /// I want issue operations to use the Gitea HTTP API directly + /// So that issue polling and replies do not depend on `tea api` /// ``` group('IssueTrackerClient Gitea', () { - /// ```gherkin - /// Scenario: Read a webhook id from tea plain-text success output - /// Given a fake tea executable whose webhook create command prints plain text instead of JSON - /// When the tracker client creates a Gitea webhook - /// Then it still extracts and returns the created webhook id - /// ``` - test('Read a webhook id from tea plain-text success output', () async { - // Given a fake tea executable whose webhook create command prints plain text instead of JSON. - final sandbox = await Directory.systemTemp.createTemp('cws-tea-webhook-'); - final teaScript = File(p.join(sandbox.path, 'tea')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail + setUp(() { + AppEnvironment.resetForTest(); + }); -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - printf '%s\n' 'Webhook created successfully (ID: 40)' - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); - final client = IssueTrackerClient(teaCommand: teaScript.path); - - // When the tracker client creates a Gitea webhook. - final webhookId = await client.createGiteaIssueWebhook( - repo: RepositorySlug('owner', 'sample'), - url: 'https://gosmee.example.test/sample', - ); - - // Then it still extracts and returns the created webhook id. - expect(webhookId, 40); + tearDown(() { + AppEnvironment.resetForTest(); }); /// ```gherkin - /// Scenario: Surface Gitea API error details when issue listing returns an error object - /// Given a fake tea executable whose issue list request returns a Gitea error object - /// When the tracker client fetches updated Gitea issues - /// Then it throws a format error that includes the repo and API error details + /// Scenario: Create a Gitea webhook through the HTTP API + /// Given a local Gitea API server that accepts webhook creation requests + /// When the tracker client creates a Gitea webhook + /// Then it returns the created webhook id + /// And the webhook options are sent to the Gitea API /// ``` - test( - 'Surface Gitea API error details when issue listing returns an error object', - () async { - // Given a fake tea executable whose issue list request returns a Gitea error object. - final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-'); - final teaScript = File(p.join(sandbox.path, 'tea')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail + test('Create a Gitea webhook through the HTTP API', () async { + // Given a local Gitea API server that accepts webhook creation requests. + final giteaServer = await FakeGiteaApiServer.start(); + _configureGiteaTestEnvironmentFromBaseUrl(giteaServer.baseUrl); + final client = IssueTrackerClient(); -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then - cat <<'JSON' -{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"} -JSON - exit 0 -fi + try { + // When the tracker client creates a Gitea webhook. + final webhookId = await client.createGiteaIssueWebhook( + repo: RepositorySlug('owner', 'sample'), + url: 'https://gosmee.example.test/sample', + ); -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); - final client = IssueTrackerClient(teaCommand: teaScript.path); + // Then it returns the created webhook id. + expect(webhookId, 81); - // When the tracker client fetches updated Gitea issues. - final future = client.fetchUpdatedIssuesForRepos( + // And the webhook options are sent to the Gitea API. + expect(giteaServer.createdWebhooks, hasLength(1)); + expect(giteaServer.createdWebhooks.single['type'], 'gitea'); + expect(giteaServer.createdWebhooks.single['active'], isTrue); + expect(giteaServer.createdWebhooks.single['events'], [ + 'issues', + 'issue_comment', + ]); + expect( + (giteaServer.createdWebhooks.single['config'] as Map)['url'], + 'https://gosmee.example.test/sample', + ); + } finally { + await giteaServer.close(); + } + }); + + /// ```gherkin + /// Scenario: Fetch Gitea issues, comments, and login through the HTTP API + /// Given a local Gitea API server with one issue and one comment + /// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment + /// Then the Gitea issue payload is normalized into the shared issue summary model + /// And the posted comment response is returned to the caller + /// ``` + test('Fetch Gitea issues, comments, and login through the HTTP API', () async { + // Given a local Gitea API server with one issue and one comment. + String? postedBody; + final server = await _startGiteaApiServer((request) async { + if (request.uri.path == '/api/v1/repos/owner/sample/issues' && + request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode(>[ + { + 'number': 12, + 'title': 'Need Gitea support', + 'body': 'Please add direct API support.', + 'state': 'open', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': >[ + {'name': 'enhancement'}, + ], + 'pull_request': null, + }, + ]), + ); + await request.response.close(); + return; + } + + if (request.uri.path == + '/api/v1/repos/owner/sample/issues/12/comments' && + request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode(>[ + { + 'id': 501, + 'body': 'Working on it.', + 'created_at': '2026-04-04T12:05:00Z', + 'updated_at': '2026-04-04T12:06:00Z', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-501', + 'user': {'login': 'maintainer'}, + }, + ]), + ); + await request.response.close(); + return; + } + + if (request.uri.path == '/api/v1/user' && request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'login': 'gitea-user'})); + await request.response.close(); + return; + } + + if (request.uri.path == + '/api/v1/repos/owner/sample/issues/12/comments' && + request.method == 'POST') { + postedBody = + (jsonDecode(await utf8.decodeStream(request)) + as Map)['body'] + as String?; + request.response + ..statusCode = HttpStatus.created + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'id': 901, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-901', + }), + ); + await request.response.close(); + return; + } + + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.json + ..write(jsonEncode({'message': 'not found'})); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final client = IssueTrackerClient(); + + try { + // When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment. + final issues = await client.fetchUpdatedIssuesForRepos( provider: IssueTrackerProvider.gitea, trackerProjects: const ['owner/sample'], since: null, ); + final thread = await client.fetchThread( + provider: IssueTrackerProvider.gitea, + trackerProject: 'owner/sample', + issue: issues.single, + ); + final login = await client.getAuthenticatedLogin( + provider: IssueTrackerProvider.gitea, + ); + final posted = await client.createIssueComment( + provider: IssueTrackerProvider.gitea, + trackerProject: 'owner/sample', + issueNumber: 12, + body: 'Reply from gitea api', + ); - // Then it throws a format error that includes the repo and API error details. - await expectLater( - future, - throwsA( - isA().having( - (error) => error.message, - 'message', - allOf( - contains('owner/sample'), - contains('GetUserByName'), - contains('user redirect does not exist'), + // Then the Gitea issue payload is normalized into the shared issue summary model. + expect(login, 'gitea-user'); + expect(issues.single.trackerProject, 'owner/sample'); + expect(issues.single.number, 12); + expect(issues.single.state, 'open'); + expect(issues.single.labels, ['enhancement']); + expect(thread.comments.single.url, contains('#issuecomment-501')); + + // And the posted comment response is returned to the caller. + expect(postedBody, 'Reply from gitea api'); + expect(posted.id, 901); + expect(posted.url, contains('#issuecomment-901')); + expect(posted.body, 'posted'); + } finally { + await server.close(force: true); + } + }); + + /// ```gherkin + /// Scenario: Reuse a tea login from the Windows app data config path + /// Given a tea config file under LOCALAPPDATA with a default Gitea login + /// When the tracker client resolves the authenticated Gitea login + /// Then it calls the Gitea HTTP API using the host and token from that config file + /// ``` + test('Reuse a tea login from the Windows app data config path', () async { + // Given a tea config file under LOCALAPPDATA with a default Gitea login. + final sandbox = await Directory.systemTemp.createTemp('cws-tea-config-'); + final localAppData = Directory(p.join(sandbox.path, 'AppData', 'Local')); + final teaConfigDir = Directory(p.join(localAppData.path, 'tea')); + await teaConfigDir.create(recursive: true); + + late final HttpServer server; + server = await _startGiteaApiServer((request) async { + expect(request.uri.path, '/api/v1/user'); + expect( + request.headers.value(HttpHeaders.authorizationHeader), + 'token config-token', + ); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'login': 'config-user'})); + await request.response.close(); + }); + + await File(p.join(teaConfigDir.path, 'config.yml')).writeAsString(''' +logins: + - name: company + url: http://${server.address.host}:${server.port} + token: config-token + default: true + insecure: true +'''); + AppEnvironment.resetForTest({ + 'LOCALAPPDATA': localAppData.path, + }); + final client = IssueTrackerClient(); + + try { + // When the tracker client resolves the authenticated Gitea login. + final login = await client.getAuthenticatedLogin( + provider: IssueTrackerProvider.gitea, + ); + + // Then it calls the Gitea HTTP API using the host and token from that config file. + expect(login, 'config-user'); + } finally { + await server.close(force: true); + await sandbox.delete(recursive: true); + } + }); + + /// ```gherkin + /// Scenario: Surface Gitea API error details when issue listing returns an error response + /// Given a local Gitea API server whose issue list request returns a Gitea error object + /// When the tracker client fetches updated Gitea issues + /// Then it throws a format error that includes the repo and API error details + /// ``` + test( + 'Surface Gitea API error details when issue listing returns an error response', + () async { + // Given a local Gitea API server whose issue list request returns a Gitea error object. + final server = await _startGiteaApiServer((request) async { + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'errors': [ + 'user redirect does not exist [name: owner]', + ], + 'message': 'GetUserByName', + 'url': 'https://gitea.example.test/api/swagger', + }), + ); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final client = IssueTrackerClient(); + + try { + // When the tracker client fetches updated Gitea issues. + final future = client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitea, + trackerProjects: const ['owner/sample'], + since: null, + ); + + // Then it throws a format error that includes the repo and API error details. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('GetUserByName'), + contains('user redirect does not exist'), + contains('404'), + ), ), ), + ); + } finally { + await server.close(force: true); + } + }, + ); + + /// ```gherkin + /// Scenario: Fail fast and log when a Gitea API request exceeds the timeout + /// Given a local Gitea API server whose issue list request never returns promptly + /// When the tracker client fetches updated Gitea issues with a short timeout + /// Then it throws a timeout instead of waiting indefinitely + /// And the tracker logger records the timed out request + /// ``` + test( + 'Fail fast and log when a Gitea API request exceeds the timeout', + () async { + // Given a local Gitea API server whose issue list request never returns promptly. + final server = await _startGiteaApiServer((request) async { + await Future.delayed(const Duration(seconds: 5)); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write('[]'); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final logMessages = []; + void listener(AppLogRecord record) { + if (record.loggerName == 'code_work_spawner.issue_tracker') { + logMessages.add(record.message); + } + } + + AppLogger.addListener(listener); + final client = IssueTrackerClient( + commandTimeout: const Duration(milliseconds: 200), + ); + + try { + // When the tracker client fetches updated Gitea issues with a short timeout. + final future = client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitea, + trackerProjects: const ['owner/sample'], + since: null, + ); + + // Then it throws a timeout instead of waiting indefinitely. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.duration, + 'duration', + const Duration(milliseconds: 200), + ), + ), + ); + } finally { + AppLogger.removeListener(listener); + await server.close(force: true); + } + + // And the tracker logger records the timed out request. + expect( + logMessages, + contains( + allOf( + contains('gitea request timeout'), + contains('timeout_ms=200'), + contains('/api/v1/repos/owner/sample/issues'), + ), ), ); }, ); }); } + +void _configureGiteaTestEnvironment(HttpServer server) { + _configureGiteaTestEnvironmentFromBaseUrl( + 'http://${server.address.host}:${server.port}', + ); +} + +void _configureGiteaTestEnvironmentFromBaseUrl(String baseUrl) { + AppEnvironment.resetForTest({ + ...Platform.environment, + 'CWS_GITEA_HOST': baseUrl, + 'CWS_GITEA_TOKEN': 'test-token', + 'CWS_GITEA_INSECURE': 'false', + }); +} + +Future _startGiteaApiServer( + Future Function(HttpRequest request) handler, +) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final request in server) { + await handler(request); + } + }()); + return server; +} diff --git a/test/support/fake_gitea_api.dart b/test/support/fake_gitea_api.dart new file mode 100644 index 0000000..91ebf59 --- /dev/null +++ b/test/support/fake_gitea_api.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +class FakeGiteaApiServer { + FakeGiteaApiServer._({ + required HttpServer server, + required this.issueListResponsesByRepo, + required this.issueListResponseSequenceByRepo, + required this.commentResponsesByRepo, + required this.postCommentResponsesByRepo, + required this.viewerLogin, + required this.token, + }) : _server = server; + + final HttpServer _server; + final Map>> issueListResponsesByRepo; + final Map>>> + issueListResponseSequenceByRepo; + final Map>>> + commentResponsesByRepo; + final Map>> postCommentResponsesByRepo; + final String viewerLogin; + final String token; + final List requestPaths = []; + final List postedBodies = []; + final List> createdWebhooks = >[]; + final List deletedWebhookIds = []; + final Map _issueListRequestCounts = {}; + + String get baseUrl => 'http://${_server.address.host}:${_server.port}'; + + int issueListRequestCount(String repo) => _issueListRequestCounts[repo] ?? 0; + + static Future start({ + Map>> issueListResponsesByRepo = + const >>{}, + Map>>> + issueListResponseSequenceByRepo = + const >>>{}, + Map>>> commentResponsesByRepo = + const >>>{}, + Map>> postCommentResponsesByRepo = + const >>{}, + String viewerLogin = 'tea-octocat', + String token = 'test-token', + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fixture = FakeGiteaApiServer._( + server: server, + issueListResponsesByRepo: issueListResponsesByRepo, + issueListResponseSequenceByRepo: issueListResponseSequenceByRepo, + commentResponsesByRepo: commentResponsesByRepo, + postCommentResponsesByRepo: postCommentResponsesByRepo, + viewerLogin: viewerLogin, + token: token, + ); + unawaited( + server.forEach((request) async { + await fixture._handleRequest(request); + }), + ); + return fixture; + } + + Future _handleRequest(HttpRequest request) async { + requestPaths.add(request.uri.toString()); + if (request.headers.value(HttpHeaders.authorizationHeader) != + 'token $token') { + request.response.statusCode = HttpStatus.unauthorized; + await request.response.close(); + return; + } + + final response = request.response..headers.contentType = ContentType.json; + if (request.method == 'GET' && request.uri.path == '/api/v1/user') { + response.write(jsonEncode({'login': viewerLogin})); + await response.close(); + return; + } + + final segments = request.uri.pathSegments; + if (segments.length >= 6 && + segments[0] == 'api' && + segments[1] == 'v1' && + segments[2] == 'repos') { + final repo = '${segments[3]}/${segments[4]}'; + if (segments.length == 6 && + segments[5] == 'hooks' && + request.method == 'POST') { + final body = await utf8.decodeStream(request); + createdWebhooks.add(jsonDecode(body) as Map); + response.statusCode = HttpStatus.created; + response.write(jsonEncode({'id': 81})); + await response.close(); + return; + } + + if (segments.length == 7 && + segments[5] == 'hooks' && + request.method == 'DELETE') { + deletedWebhookIds.add(int.parse(segments[6])); + response.statusCode = HttpStatus.noContent; + await response.close(); + return; + } + + if (segments.length == 6 && + segments[5] == 'issues' && + request.method == 'GET') { + final requestCount = (_issueListRequestCounts[repo] ?? 0) + 1; + _issueListRequestCounts[repo] = requestCount; + final sequence = issueListResponseSequenceByRepo[repo]; + final payload = sequence == null || sequence.isEmpty + ? issueListResponsesByRepo[repo] ?? const >[] + : sequence[(requestCount - 1).clamp(0, sequence.length - 1)]; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + + if (segments.length == 8 && + segments[5] == 'issues' && + segments[7] == 'comments') { + final issueNumber = int.parse(segments[6]); + if (request.method == 'GET') { + final payload = + commentResponsesByRepo[repo]?[issueNumber] ?? + const >[]; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + + if (request.method == 'POST') { + final body = await utf8.decodeStream(request); + postedBodies.add(body); + final payload = + postCommentResponsesByRepo[repo]?[issueNumber] ?? + { + 'id': 700, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/$repo/issues/$issueNumber#issuecomment-700', + }; + response.statusCode = HttpStatus.created; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + } + } + + response.statusCode = HttpStatus.notFound; + response.write(jsonEncode({'message': 'not found'})); + await response.close(); + } + + Future close() => _server.close(force: true); +} diff --git a/test/test_support.dart b/test/test_support.dart index aea7198..97861a6 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -2,6 +2,7 @@ export 'support/script_utils.dart'; export 'support/fake_gh.dart'; export 'support/fake_tea.dart'; export 'support/fake_glab.dart'; +export 'support/fake_gitea_api.dart'; export 'support/fake_responders.dart'; export 'support/fake_gosmee.dart'; export 'support/git_utils.dart'; diff --git a/tool/process_mode_probe.dart b/tool/process_mode_probe.dart new file mode 100644 index 0000000..4279ac1 --- /dev/null +++ b/tool/process_mode_probe.dart @@ -0,0 +1,292 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:code_work_spawner/src/core/process_launcher.dart'; + +// The file is used to probe different modes of running external processes in Dart + +// Usage example: +// Running command with Process.runSync: tea api -X GET "repos/bkinnightskytw/code_work_spawner/issues?state=open&limit=100&page=1&since=2026-04-10T06%3A06%3A25.000Z" -r bkinnightskytw/code_work_spawner +// `dart run .\tool\process_mode_probe.dart --mode process-run-sync --command tea --arg api --arg -X --arg GET --arg '"repos/bkinnightskytw/code_work_spawner/issues?state=open&limit=100&page=1"' --arg -r --arg 'bkinnightskytw/code_work_spawner'` +// `dart run .\tool\process_mode_probe.dart --mode process-run-sync --command tea --arg login --arg default` +// Aware that `tea` has known severe issues on Windows due to lipgloss/v2 query console color. +// + +Future main(List arguments) async { + final parser = ArgParser() + ..addOption( + 'command', + help: 'Executable to run, for example `tea` or `gh`.', + mandatory: true, + ) + ..addMultiOption( + 'arg', + help: 'Repeat for each argument that should be passed to the command.', + ) + ..addOption( + 'mode', + help: + 'Comma-separated modes: all, launcher, process-run, process-run-sync, process-start, powershell-command.', + defaultsTo: 'all', + ) + ..addOption( + 'timeout-ms', + help: 'Per-mode timeout in milliseconds.', + defaultsTo: '5000', + ) + ..addOption( + 'working-directory', + help: 'Optional working directory for the command.', + ); + + // add help + parser.addFlag( + 'help', + abbr: 'h', + help: 'Show this help message.', + negatable: false, + ); + + final results = parser.parse(arguments); + final command = results['command'] as String; + final args = (results['arg'] as List).toList(growable: false); + final timeout = Duration( + milliseconds: int.parse(results['timeout-ms'] as String), + ); + final workingDirectory = results['working-directory'] as String?; + final selectedModes = _parseModes(results['mode'] as String); + final resolved = resolveExternalCommandForCurrentPlatform(command, args); + + final payload = { + 'command': command, + 'arguments': args, + 'timeout_ms': timeout.inMilliseconds, + 'working_directory': workingDirectory, + 'resolved': { + 'executable': resolved.executable, + 'arguments': resolved.arguments, + 'run_in_shell': resolved.runInShell, + 'uses_bash_shim': resolved.usesBashShim, + }, + 'results': [], + }; + final resultList = payload['results'] as List; + + for (final mode in selectedModes) { + resultList.add( + await _runMode( + mode, + command: command, + arguments: args, + timeout: timeout, + workingDirectory: workingDirectory, + ), + ); + } + + stdout.writeln(const JsonEncoder.withIndent(' ').convert(payload)); +} + +List _parseModes(String configuredModes) { + final trimmedModes = configuredModes + .split(',') + .map((mode) => mode.trim()) + .where((mode) => mode.isNotEmpty) + .toList(growable: false); + if (trimmedModes.contains('all')) { + return const [ + 'launcher', + 'process-run', + 'process-run-sync', + 'process-start', + 'powershell-command', + ]; + } + return trimmedModes; +} + +Future> _runMode( + String mode, { + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final stopwatch = Stopwatch()..start(); + try { + final result = switch (mode) { + 'launcher' => await runExternalCommand( + command, + arguments, + workingDirectory: workingDirectory, + timeout: timeout, + ), + 'process-run' => + await Process.run( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + timeout, + onTimeout: () { + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ), + 'process-start' => await _runWithProcessStart( + command: command, + arguments: arguments, + timeout: timeout, + workingDirectory: workingDirectory, + ), + 'process-run-sync' => _runWithProcessRunSync( + command: command, + arguments: arguments, + workingDirectory: workingDirectory, + ), + 'powershell-command' => await _runViaPowerShellCommand( + command: command, + arguments: arguments, + timeout: timeout, + workingDirectory: workingDirectory, + ), + _ => throw ArgumentError('Unsupported mode: $mode'), + }; + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'success', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'exit_code': result.exitCode, + 'stdout_length': result.stdout.toString().length, + 'stderr_length': result.stderr.toString().length, + 'stdout_preview': _preview(result.stdout.toString()), + 'stderr_preview': _preview(result.stderr.toString()), + }; + } on TimeoutException catch (error) { + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'timeout', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'message': error.message, + }; + } catch (error, stackTrace) { + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'error', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'error': error.toString(), + 'stack_preview': _preview(stackTrace.toString()), + }; + } +} + +Future _runWithProcessStart({ + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final process = await Process.start( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + ); + await process.stdin.close(); + final stdoutFuture = process.stdout.transform(utf8.decoder).join(); + final stderrFuture = process.stderr.transform(utf8.decoder).join(); + final exitCode = await process.exitCode.timeout( + timeout, + onTimeout: () { + process.kill(); + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ); + return ProcessResult( + process.pid, + exitCode, + await stdoutFuture, + await stderrFuture, + ); +} + +ProcessResult _runWithProcessRunSync({ + required String command, + required List arguments, + required String? workingDirectory, +}) { + // print + print( + 'Running command with Process.runSync: $command ${arguments.join(' ')}', + ); + return Process.runSync( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); +} + +Future _runViaPowerShellCommand({ + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final powerShellScript = r''' +$ErrorActionPreference = 'Stop' +$command = $args[0] +$commandArgs = if ($args.Length -gt 1) { $args[1..($args.Length - 1)] } else { @() } +& $command @commandArgs +if ($null -ne $LASTEXITCODE) { + exit $LASTEXITCODE +} +'''; + + return Process.run( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + powerShellScript, + command, + ...arguments, + ], + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + timeout, + onTimeout: () { + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ); +} + +String _preview(String text, {int maxLength = 200}) { + final singleLine = text.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return '${singleLine.substring(0, maxLength)}...'; +} From c55643337c3067743ee850ae270e5aa9da3bc310 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 11 Apr 2026 15:34:20 +0800 Subject: [PATCH 2/2] refactor: remove shell-dependent test seams from CLI integration paths The Windows process launcher no longer needs Bash/PowerShell mediation, and the event-source tests now exercise CLI boundaries through an injectable executor instead of generated scripts. This keeps production behavior on real external commands while letting tests model long-running process streams and kill semantics in Dart. Constraint: Tests must not mock CLIs by writing generated Dart or shell scripts and executing them. Rejected: Use generated Dart script fixtures | still depends on subprocess execution for mocks. Rejected: Use only mocktail mocks for process handles | stream, kill, and exit behavior are clearer as a small stateful fake. Confidence: high Scope-risk: moderate Directive: Keep long-running CLI test behavior behind ExternalCommandExecutor rather than reintroducing script fixtures. Tested: dart analyze; dart run tool\\validate_gherkin_test_format.dart; dart test Not-tested: Real gh/gosmee webhook forwarding against live services --- .gitignore | 3 + lib/src/core/issue_assistant_app.dart | 12 +- lib/src/core/process_launcher.dart | 377 ++++----- lib/src/issue_tracker/client.dart | 8 +- .../issue_event_source/gitea_gosmee.dart | 8 +- .../issue_event_source/github_gosmee.dart | 8 +- .../github_webhook_forward.dart | 19 +- .../issue_event_source/gitlab_gosmee.dart | 8 +- pubspec.yaml | 1 + test/core/concurrency_test.dart | 6 + test/core/core_test.dart | 10 + test/core/multi_project_test.dart | 6 + test/core/process_launcher_test.dart | 62 +- test/core/retry_test.dart | 2 + .../github_gosmee_test.dart | 2 + .../gitlab_gosmee_test.dart | 2 + test/issue_event_source/gosmee_test.dart | 28 +- .../webhook_forward_test.dart | 206 ++--- test/issue_tracker_client_test.dart | 5 +- test/notifier/notifications_test.dart | 4 + test/support/fake_gh.dart | 735 +++++++----------- test/support/fake_glab.dart | 209 +++-- test/support/fake_gosmee.dart | 142 ++-- test/support/fake_responders.dart | 205 ++++- test/support/fake_tea.dart | 173 ++--- test/support/script_utils.dart | 146 +++- test/workspace/worktree_test.dart | 44 +- tool/process_mode_probe.dart | 1 - 28 files changed, 1232 insertions(+), 1200 deletions(-) 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': [], };