// ignore_for_file: depend_on_referenced_packages, library_private_types_in_public_api import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dart_framework/platform/process.dart'; import 'package:oto/oto/application.dart'; import 'package:oto/oto/commands/build/build_flutter.dart'; import 'package:oto/oto/commands/build/build_ios.dart'; import 'package:oto/oto/commands/build/build_msbuild.dart'; import 'package:oto/oto/commands/command_runtime.dart'; // ignore: implementation_imports import 'package:dart_framework/utils/path.dart' show FileType; import 'package:oto/oto/commands/ftp/download.dart'; import 'package:oto/oto/commands/ftp/ftp.dart'; import 'package:oto/oto/commands/git/git.dart'; import 'package:oto/oto/commands/git/git_hub.dart'; import 'package:oto/oto/commands/git/git_remote.dart'; import 'package:oto/oto/commands/infra/smb_authorize.dart'; import 'package:oto/oto/commands/process/process.dart'; import 'package:oto/oto/commands/shell/shell.dart'; import 'package:oto/oto/commands/util/execute_app.dart'; import 'package:oto/oto/commands/util/json.dart'; import 'package:oto/oto/core/execution_context.dart'; import 'package:oto/oto/data/command_data.dart'; import 'package:test/test.dart'; class _StartCall { final String shell; final String? workspace; final bool printStdout; final bool printStderr; final Converter, String>? decoder; _StartCall(this.shell, this.workspace, this.printStdout, this.printStderr, this.decoder); } class _RunCall { final String shell; final String? workspace; _RunCall(this.shell, this.workspace); } class _DetachedCall { final String shell; final String workspace; _DetachedCall(this.shell, this.workspace); } class _ExecutableCall { final String exe; final List args; final String? workspace; final bool? printStdout; final bool? printStderr; _ExecutableCall( this.exe, this.args, this.workspace, this.printStdout, this.printStderr); } class FakeProcessData extends ProcessData { FakeProcessData( {int exitCode = 0, String stdoutText = '', String stderrText = ''}) : super(false, false) { this.exitCode = exitCode; this.stdout = StringBuffer(stdoutText); this.stderr = StringBuffer(stderrText); } @override Future waitForExit() async {} @override Process get process => throw UnsupportedError('FakeProcessData has no real process'); } class FakeRuntime implements CommandRuntime { final List<_StartCall> startCalls = []; final List<_RunCall> runCalls = []; final List<_DetachedCall> detachedCalls = []; final List<_ExecutableCall> executableCalls = []; int exitCode; String stdoutText; String stderrText; final List startExitCodes; FakeRuntime( {this.exitCode = 0, this.stdoutText = '', this.stderrText = '', this.startExitCodes = const []}); @override Future start(StringBuffer shell, {String? workspace, bool printStdout = true, bool printStderr = true, Converter, String>? decoder, LogHandler? logHandler}) async { final startExitCode = startCalls.length < startExitCodes.length ? startExitCodes[startCalls.length] : exitCode; startCalls.add(_StartCall( shell.toString(), workspace, printStdout, printStderr, decoder)); return FakeProcessData( exitCode: startExitCode, stdoutText: stdoutText, stderrText: stderrText); } @override Future run(StringBuffer shell, {String? workspace, bool printStdout = true, bool printStderr = true, Converter, String>? decoder, LogHandler? logHandler}) async { runCalls.add(_RunCall(shell.toString(), workspace)); return ProcessResult(0, exitCode, stdoutText, stderrText); } @override Future runExecutable(String exe, List args, {String? workspace, bool? printStdout, bool? printStderr, LogHandler? logHandler}) async { executableCalls .add(_ExecutableCall(exe, args, workspace, printStdout, printStderr)); return ProcessResult(0, exitCode, stdoutText, stderrText); } @override Future startDetached(StringBuffer shell, {required String workspace}) async { detachedCalls.add(_DetachedCall(shell.toString(), workspace)); } } void main() { setUp(() { Application.instance.context = ExecutionContext(); Application.instance.property['workspace'] = '/tmp/oto_runtime_test'; }); DataCommand commandWith(Map param) { return DataCommand.fromJson({ 'command': 'Shell', 'id': 'fake', 'param': param, }); } test('shell command delegates process start to runtime', () async { final fake = FakeRuntime(); final shell = Shell()..runtime = fake; final command = commandWith({ 'commands': ['echo hello'], }); await shell.execute(command); expect(fake.startCalls, hasLength(1)); expect(fake.startCalls.single.shell, contains('echo hello')); expect(fake.startCalls.single.shell, contains('/tmp/oto_runtime_test')); expect(fake.runCalls, isEmpty); expect(fake.detachedCalls, isEmpty); }); test('git command delegates command buffer to runtime', () async { final fake = FakeRuntime(); final git = Git()..runtime = fake; final command = commandWith({ 'commands': ['status', 'fetch origin'], }); await git.execute(command); expect(fake.startCalls, hasLength(1)); final buffer = fake.startCalls.single.shell; expect(buffer, contains('git status')); expect(buffer, contains('git fetch origin')); expect(fake.detachedCalls, isEmpty); }); test('process run delegates detached start to runtime', () async { final fake = FakeRuntime(); final processRun = ProcessRun()..runtime = fake; final command = commandWith({ 'executable': 'my_server', 'param': '--port 8080', 'workspace': '/tmp/oto_runtime_test', }); await processRun.execute(command); expect(fake.detachedCalls, hasLength(1)); expect(fake.detachedCalls.single.workspace, '/tmp/oto_runtime_test'); expect(fake.detachedCalls.single.shell, contains('my_server')); expect(fake.detachedCalls.single.shell, contains('--port 8080')); expect(fake.detachedCalls.single.shell, contains('dontKillMe')); expect(fake.startCalls, isEmpty); }); test('runtime captures start options', () async { final fake = FakeRuntime(); final shell = StringBuffer('echo hello'); await fake.start(shell, workspace: '/tmp/test', printStdout: false, printStderr: false); expect(fake.startCalls, hasLength(1)); expect(fake.startCalls.single.workspace, '/tmp/test'); expect(fake.startCalls.single.printStdout, isFalse); expect(fake.startCalls.single.printStderr, isFalse); }); test('runtime captures run workspace', () async { final fake = FakeRuntime(); final shell = StringBuffer('echo test'); await fake.run(shell, workspace: '/some/workspace'); expect(fake.runCalls, hasLength(1)); expect(fake.runCalls.single.workspace, '/some/workspace'); expect(fake.runCalls.single.shell, contains('echo test')); }); test('build_msbuild delegates start with decoder and printStderr=false', () async { final fake = FakeRuntime(); final msbuild = BuildMSBuild()..runtime = fake; final command = commandWith({ 'projectFile': 'MyApp.sln', 'type': 'Build', 'config': 'Release', }); await msbuild.execute(command); expect(fake.startCalls, hasLength(1)); expect(fake.startCalls.single.printStderr, isFalse); expect(fake.startCalls.single.decoder, isNotNull); expect(fake.startCalls.single.shell, contains('msbuild MyApp.sln')); }); test('github_pull_request_list delegates start with decoder', () async { final fake = FakeRuntime(stdoutText: '[]'); final prList = GitHubPullRequestList()..runtime = fake; final command = commandWith({ 'keys': ['number', 'title'], 'targetKey': 'title', 'targetValue': 'my-pr', 'setValue': '<@property.pr>', }); await prList.execute(command); expect(fake.startCalls, hasLength(1)); expect(fake.startCalls.single.decoder, isNotNull); expect(fake.startCalls.single.shell, contains('gh pr list')); }); test('execute_app delegates run to runtime with workspace', () async { final fake = FakeRuntime(); final app = ExecuteApp()..runtime = fake; final command = commandWith({ 'executable': 'MyApp.exe', 'desktopServicePath': '/some/desktop', }); await app.execute(command); expect(fake.runCalls, hasLength(1)); expect(fake.runCalls.single.workspace, '/some/desktop'); expect(fake.runCalls.single.shell, contains('MyApp.exe')); }); test('BuildiOS.initializeFastlane delegates fastlane init run to runtime', () async { final fake = FakeRuntime(); final tmp = await Directory.systemTemp.createTemp('oto_ios_test_'); try { final apiKeyFile = File('${tmp.path}/key.json'); await apiKeyFile.writeAsString(jsonEncode({ 'key_id': 'KID', 'issuer_id': 'IID', 'key': 'content', })); await Directory('${tmp.path}/fastlane').create(); await BuildiOS.initializeFastlane( tmp.path, apiKeyFile.path, 'com.test.app', runtime: fake); expect(fake.runCalls, hasLength(1)); expect(fake.runCalls.single.shell, contains('fastlane init')); } finally { await tmp.delete(recursive: true); } }); test('BuildiOS.execute delegates start to runtime via ProcessData.exitCode', () async { final fake = FakeRuntime(); final tmp = await Directory.systemTemp.createTemp('oto_ios_main_'); try { await Directory('${tmp.path}/MyApp.xcodeproj').create(); await File('${tmp.path}/MyApp.xcodeproj/project.pbxproj') .writeAsString(''); final ios = BuildiOS()..runtime = fake; final command = commandWith({ 'xcodeProjectFilePath': 'MyApp.xcodeproj', 'scheme': 'MyApp', 'version': '1.0.0', 'buildNumber': '100', 'workspace': tmp.path, }); await ios.execute(command); expect(fake.startCalls, hasLength(1)); expect(fake.startCalls.single.shell, contains('xcodebuild')); expect(fake.startCalls.single.printStderr, isFalse); } finally { await tmp.delete(recursive: true); } }); test('BuildiOS.execute shell includes derivedDataPath and macPassword', () async { final fake = FakeRuntime(); final tmp = await Directory.systemTemp.createTemp('oto_ios_opts_'); try { await Directory('${tmp.path}/MyApp.xcodeproj').create(); await File('${tmp.path}/MyApp.xcodeproj/project.pbxproj') .writeAsString(''); final ios = BuildiOS()..runtime = fake; final command = commandWith({ 'xcodeProjectFilePath': 'MyApp.xcodeproj', 'scheme': 'MyApp', 'version': '1.0.0', 'buildNumber': '100', 'workspace': tmp.path, 'derivedDataPath': '/tmp/derived', 'macPassword': 'testpwd', }); await ios.execute(command); final shell = fake.startCalls.single.shell; expect(shell, contains('-derivedDataPath /tmp/derived')); expect(shell, contains('security unlock-keychain -ptestpwd')); } finally { await tmp.delete(recursive: true); } }); test('BuildFlutter uses resolved workspace, flavor, and platform in shell', () async { final fake = FakeRuntime(); final buildFlutter = BuildFlutter()..runtime = fake; Application.instance.property['workspace'] = '/custom/workspace'; Application.instance.property['flavor'] = 'production'; Application.instance.property['platform'] = 'apk'; final command = DataCommand.fromJson({ 'command': 'BuildFlutter', 'id': 'build-flutter-test', 'param': { 'workspace': '', 'platform': '', 'flavor': '', 'additional': '--release', 'executeBuildRunner': false, }, }); await buildFlutter.execute(command); expect(fake.startCalls, hasLength(1)); final shell = fake.startCalls.single.shell; expect(shell, contains('cd /custom/workspace')); expect(shell, contains('flutter build apk')); expect(shell, contains('--flavor production')); expect(shell, contains('--release')); }); test('CodeSign includes --entitlements only when entitlementPath is set', () async { final fakeWith = FakeRuntime(); final fakeWithout = FakeRuntime(); final csWithEntitlement = CodeSign()..runtime = fakeWith; final csWithout = CodeSign()..runtime = fakeWithout; await csWithEntitlement.execute(commandWith({ 'certificationName': 'Apple Dev', 'appPath': '/path/App.app', 'entitlementPath': '/path/app.entitlements', })); await csWithout.execute(commandWith({ 'certificationName': 'Apple Dev', 'appPath': '/path/App.app', })); expect(fakeWith.startCalls.single.shell, contains('--entitlements')); expect( fakeWithout.startCalls.single.shell, isNot(contains('--entitlements'))); }); test('runtime captures executable arguments', () async { final fake = FakeRuntime(exitCode: 0, stdoutText: 'done'); final result = await fake.runExecutable('my_exe', ['--flag', 'value'], workspace: '/tmp', printStdout: false, printStderr: false); expect(fake.executableCalls, hasLength(1)); expect(fake.executableCalls.single.exe, 'my_exe'); expect(fake.executableCalls.single.args, containsAll(['--flag', 'value'])); expect(fake.executableCalls.single.workspace, '/tmp'); expect(fake.executableCalls.single.printStdout, isFalse); expect(fake.executableCalls.single.printStderr, isFalse); expect(result.exitCode, 0); expect(result.stdout, 'done'); }); test('sftp list parses ls -l stdout into FileType map', () async { const lsOutput = 'total 8\n' 'drwxr-xr-x 2 user group 4096 Jan 1 00:00 subdir\n' '-rw-r--r-- 1 user group 1234 Jan 1 00:00 file.txt\n'; final fake = FakeRuntime(stdoutText: lsOutput); final sftp = SFTP('test.host', user: 'user', password: 'pass', runtime: fake); final result = await sftp.list('/remote'); expect(fake.executableCalls, hasLength(1)); expect(fake.executableCalls.single.exe, 'sshpass'); expect(fake.executableCalls.single.args, containsAll(['ssh', 'ls -l /remote'])); expect(result['/remote/subdir'], FileType.directory); expect(result['/remote/file.txt'], FileType.file); }); test('sftp list propagates non-zero exit code as exception', () async { final fake = FakeRuntime(exitCode: 1, stderrText: 'ls failed'); final sftp = SFTP('test.host', user: 'user', password: 'pass', runtime: fake); await expectLater( sftp.list('/remote'), throwsA(predicate((e) => e.toString().contains('ls failed'))), ); }); test('sftp delete calls sshpass ssh rm -rf', () async { final fake = FakeRuntime(); final sftp = SFTP('test.host', user: 'user', password: 'pass', runtime: fake); await sftp.delete(['/remote/old.txt']); expect(fake.executableCalls, hasLength(1)); expect(fake.executableCalls.single.exe, 'sshpass'); final args = fake.executableCalls.single.args; expect(args, containsAll(['ssh', 'rm -rf /remote/old.txt'])); }); test('smb_auth delegates cmd net use to runtime with domain user password', () async { final fake = FakeRuntime(exitCode: 0); final smb = SMBAuth()..runtime = fake; final command = commandWith({ 'domain': 'fileserver', 'id': 'testuser', 'password': 'secret', }); await smb.execute(command); expect(fake.executableCalls, hasLength(1)); expect(fake.executableCalls.single.exe, 'cmd'); final args = fake.executableCalls.single.args; expect( args, containsAll( ['/C', 'net', 'use', r'\\fileserver', '/user:testuser', 'secret'])); }); test('download maps local relative path to remote path for sftp', () async { const lsOutput = 'total 4\n' '-rw-r--r-- 1 user group 1234 Jan 1 00:00 app.json\n'; final fake = FakeRuntime(stdoutText: lsOutput); final download = Download()..runtime = fake; final command = DataCommand.fromJson({ 'command': 'Download', 'id': 'download-config', 'param': { 'host': 'test.host', 'sftp': true, 'port': 22, 'user': 'user', 'password': 'pass', 'map': { 'config/app.json': '/remote/config/app.json', }, }, }); await download.execute(command); expect(fake.executableCalls, hasLength(2)); expect(fake.executableCalls.first.args, containsAll(['ssh', 'ls -l /remote/config'])); final scpArgs = fake.executableCalls.last.args; expect(scpArgs, containsAll(['scp', '-P', '22'])); expect(scpArgs, contains('user@test.host:/remote/config/app.json')); expect(scpArgs, contains('/tmp/oto_runtime_test/config/app.json')); }); test('sftp download failure propagates non-zero exit code as exception', () async { final fake = FakeRuntime(exitCode: 1, stderrText: 'ssh: connect failed'); final download = Download()..runtime = fake; final command = DataCommand.fromJson({ 'command': 'Download', 'id': 'download-fail', 'param': { 'host': 'test.host', 'sftp': true, 'port': 22, 'user': 'user', 'password': 'pass', 'map': { 'config/app.json': '/remote/config/app.json', }, }, }); await expectLater( download.execute(command), throwsA(predicate((e) => e.toString().contains('ssh: connect failed'))), ); }); test('git_pull propagates exit code 1 as failure', () async { final fake = FakeRuntime(exitCode: 1, stderrText: 'remote rejected'); final gitPull = GitPull()..runtime = fake; final command = commandWith({'branch': 'main'}); await expectLater( gitPull.execute(command), throwsA(predicate((e) => e.toString().contains('remote rejected'))), ); }); test('git_push propagates exit code 1 as failure', () async { final fake = FakeRuntime(exitCode: 1, stderrText: 'remote rejected'); final gitPush = GitPush()..runtime = fake; final command = commandWith({'branch': 'main'}); await expectLater( gitPush.execute(command), throwsA(predicate((e) => e.toString().contains('remote rejected'))), ); }); test('git_checkout uses fake ProcessData exitCode for existing branch', () async { final fake = FakeRuntime(startExitCodes: [128, 0]); final gitCheckout = GitCheckout()..runtime = fake; final command = commandWith({'branch': 'develop', 'executePull': false}); await gitCheckout.execute(command); expect(fake.startCalls, hasLength(2)); expect( fake.startCalls.first.shell, contains('git checkout origin/develop')); expect(fake.startCalls.first.shell, contains('git checkout -b develop')); expect(fake.startCalls.last.shell, contains('git checkout develop')); expect(fake.startCalls.last.shell, isNot(contains('git pull origin'))); }); test('json_reader array path stores value without debug print', () async { final printed = []; final reader = JsonReader(); final command = DataCommand.fromJson({ 'command': 'JsonReader', 'id': 'json-reader', 'param': { 'json': { 'items': [ {'name': 'alpha'} ], }, 'pair': [ {'from': 'items[0].name', 'setValue': '<@property.itemName>'} ], }, }); await runZoned( () => reader.execute(command), zoneSpecification: ZoneSpecification( print: (self, parent, zone, line) => printed.add(line), ), ); expect(Application.instance.property['itemName'], 'alpha'); expect(printed, isNot(contains('1'))); }); test('json_writer_file updates file without debug print', () async { final printed = []; final tmp = await Directory.systemTemp.createTemp('oto_json_writer_'); try { final file = File('${tmp.path}/data.json'); await file.writeAsString(jsonEncode({ 'nested': {'old': 'value'} })); final writer = JsonWriterFile(); final command = DataCommand.fromJson({ 'command': 'JsonWriterFile', 'id': 'json-writer', 'param': { 'path': file.path, 'pair': [ {'from': 'nested.newValue', 'value': '42'} ], }, }); await runZoned( () => writer.execute(command), zoneSpecification: ZoneSpecification( print: (self, parent, zone, line) => printed.add(line), ), ); final json = jsonDecode(await file.readAsString()) as Map; expect((json['nested'] as Map)['newValue'], '42'); expect(printed, isEmpty); } finally { await tmp.delete(recursive: true); } }); test('github_pull_request_create treats existing-pr message as success', () async { const existingUrl = 'https://github.com/org/repo/pull/123'; final fake = FakeRuntime( exitCode: 1, stderrText: 'GraphQL: a pull request for branch feature already exists:\n$existingUrl', ); final prCreate = GitHubPullRequestCreate()..runtime = fake; final command = commandWith({ 'title': 'My PR', 'body': 'Description', 'branch': 'main', 'setURL': '<@property.prUrl>', 'setNumber': '<@property.prNumber>', }); await prCreate.execute(command); expect(Application.instance.property['prUrl'], existingUrl); expect(Application.instance.property['prNumber'], '123'); }); test('github_pull_request_create propagates unrelated exit code 1', () async { final fake = FakeRuntime(exitCode: 1, stderrText: 'authentication failed'); final prCreate = GitHubPullRequestCreate()..runtime = fake; final command = commandWith({ 'title': 'My PR', 'body': 'Description', 'branch': 'main', 'setURL': '<@property.prUrl>', 'setNumber': '<@property.prNumber>', }); await expectLater( prCreate.execute(command), throwsA(predicate((e) => e.toString().contains('authentication failed'))), ); }); }