495 lines
16 KiB
Dart
495 lines
16 KiB
Dart
// 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_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/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/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<List<int>, 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<String> 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;
|
|
|
|
FakeRuntime({this.exitCode = 0, this.stdoutText = '', this.stderrText = ''});
|
|
|
|
@override
|
|
Future<ProcessData> start(StringBuffer shell,
|
|
{String? workspace,
|
|
bool printStdout = true,
|
|
bool printStderr = true,
|
|
Converter<List<int>, String>? decoder,
|
|
LogHandler? logHandler}) async {
|
|
startCalls.add(_StartCall(
|
|
shell.toString(), workspace, printStdout, printStderr, decoder));
|
|
return FakeProcessData(exitCode: exitCode, stdoutText: stdoutText);
|
|
}
|
|
|
|
@override
|
|
Future<ProcessResult> run(StringBuffer shell,
|
|
{String? workspace,
|
|
bool printStdout = true,
|
|
bool printStderr = true,
|
|
Converter<List<int>, String>? decoder,
|
|
LogHandler? logHandler}) async {
|
|
runCalls.add(_RunCall(shell.toString(), workspace));
|
|
return ProcessResult(0, exitCode, stdoutText, stderrText);
|
|
}
|
|
|
|
@override
|
|
Future<ProcessResult> runExecutable(String exe, List<String> 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<void> 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<String, dynamic> 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('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 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'))),
|
|
);
|
|
});
|
|
}
|