oto/apps/runner/test/oto_system_runtime_test.dart
toki fcde95985d refactor(runner): 출력 포트 경계를 연결한다
core data composition과 Application 출력 경계에서 CLI Color 의존을 분리하고, 기존 CLI 출력 의미를 adapter 테스트로 보존하기 위해 변경한다.
2026-06-07 20:42:26 +09:00

388 lines
11 KiB
Dart

// ignore_for_file: depend_on_referenced_packages, library_private_types_in_public_api
import 'dart:convert';
import 'dart:io';
import 'package:dart_framework/platform/process.dart';
import 'package:oto/oto/application.dart';
import 'package:oto/oto/core/data_composer.dart';
import 'package:oto/oto/core/output_port.dart';
import 'package:oto/oto/core/system_runtime.dart';
import 'package:test/test.dart';
class _ShellCall {
final String shell;
final String? workspace;
final bool printStdout;
final bool printStderr;
final Converter<List<int>, String>? decoder;
_ShellCall(
this.shell,
this.workspace,
this.printStdout,
this.printStderr,
this.decoder,
);
}
class _RunExecutableCall {
final String exe;
final List<String> args;
final String? workingDirectory;
_RunExecutableCall(this.exe, this.args, this.workingDirectory);
}
class _StartExecutableCall {
final String program;
final List<String> args;
final String? workingDirectory;
final ProcessStartMode mode;
_StartExecutableCall(
this.program,
this.args,
this.workingDirectory,
this.mode,
);
}
class FakeProcessData extends ProcessData {
FakeProcessData({String stdoutText = ''}) : super(false, false) {
exitCode = 0;
this.stdout = StringBuffer(stdoutText);
}
@override
Future waitForExit() async {}
@override
Process get process =>
throw UnsupportedError('FakeProcessData has no real process');
}
class FakeSystemRuntime implements SystemRuntime {
final List<_ShellCall> startShellCalls = [];
final List<_ShellCall> runShellCalls = [];
final List<_RunExecutableCall> runExecutableCalls = [];
final List<_StartExecutableCall> startExecutableCalls = [];
final List<int> killedPids = [];
final bool _isWindows;
final bool _isMacOS;
final bool _isLinux;
final Map<String, String> _environment;
final String runShellStdout;
final String startShellStdout;
FakeSystemRuntime({
bool isWindows = false,
bool isMacOS = false,
bool isLinux = true,
Map<String, String> environment = const {},
this.runShellStdout = '',
this.startShellStdout = '',
}) : _isWindows = isWindows,
_isMacOS = isMacOS,
_isLinux = isLinux,
_environment = environment;
@override
bool get isWindows => _isWindows;
@override
bool get isMacOS => _isMacOS;
@override
bool get isLinux => _isLinux;
@override
Map<String, String> get environment => _environment;
@override
Future<ProcessData> startShell(
StringBuffer shell, {
String? workspace,
bool printStdout = true,
bool printStderr = true,
Converter<List<int>, String>? decoder,
LogHandler? logHandler,
}) async {
startShellCalls.add(
_ShellCall(
shell.toString(),
workspace,
printStdout,
printStderr,
decoder,
),
);
return FakeProcessData(stdoutText: startShellStdout);
}
@override
Future<ProcessResult> runShell(
StringBuffer shell, {
String? workspace,
bool printStdout = true,
bool printStderr = true,
Converter<List<int>, String>? decoder,
LogHandler? logHandler,
}) async {
runShellCalls.add(
_ShellCall(
shell.toString(),
workspace,
printStdout,
printStderr,
decoder,
),
);
return ProcessResult(0, 0, runShellStdout, '');
}
@override
Future<ProcessResult> runExecutable(
String exe,
List<String> args, {
String? workingDirectory,
Encoding? stdoutEncoding,
Encoding? stderrEncoding,
}) async {
runExecutableCalls.add(_RunExecutableCall(exe, args, workingDirectory));
return ProcessResult(0, 0, '', '');
}
@override
Future<Process> startExecutable(
String program,
List<String> args, {
String? workingDirectory,
ProcessStartMode mode = ProcessStartMode.normal,
}) async {
startExecutableCalls.add(
_StartExecutableCall(program, args, workingDirectory, mode),
);
throw UnsupportedError('FakeSystemRuntime does not return a real Process');
}
@override
bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) {
killedPids.add(pid);
return true;
}
}
void main() {
test('setUTF8 uses injected system runtime on Windows', () async {
final fake = FakeSystemRuntime(isWindows: true);
Application.instance.systemRuntime = fake;
await Application.instance.setUTF8();
expect(fake.startShellCalls, hasLength(1));
expect(fake.startShellCalls.single.shell, contains('chcp 65001'));
Application.instance.systemRuntime = const DefaultSystemRuntime();
});
test(
'DataComposerJenkins composes linux env and BuildData from runtime stdout',
() async {
const jenkinsJson = '''
{
"workspace": "/workspace",
"jenkinsHome": "/jenkins",
"jenkinsUrl": "http://jenkins/",
"buildUrl": "http://jenkins/build/1/",
"jobUrl": "http://jenkins/job/myjob/",
"jobName": "myjob",
"gitCommit": "abc123",
"buildNumber": 42,
"buildID": 42,
"buildDisplayName": "#42",
"gitBranch": "main",
"buildTag": "myjob-42"
}
''';
const buildYaml = 'property:\n workspace: .\ncommands: []\n';
final fake = FakeSystemRuntime(
isLinux: true,
runShellStdout: jenkinsJson,
startShellStdout: buildYaml,
);
final composer = DataComposerJenkins(runtime: fake);
final output = _RecordingOutputPort();
await composer.compose(null, output);
expect(fake.runShellCalls, hasLength(1));
expect(fake.startShellCalls, hasLength(1));
expect(fake.startShellCalls.single.printStdout, isFalse);
expect(composer.commonData, isNotNull);
expect(composer.commonData!.workspace, '/workspace');
expect(composer.buildYaml, buildYaml);
expect(output.steps.single.message, 'Jenkins Variables');
expect(output.steps.single.style, RunnerOutputStyle.success);
},
);
test(
'JenkinsEnvironmentCollector collects linux env and build yaml from runtime',
() async {
const jenkinsJson = '''
{
"workspace": "/workspace",
"jenkinsHome": "/jenkins",
"jenkinsUrl": "http://jenkins/",
"buildUrl": "http://jenkins/build/1/",
"jobUrl": "http://jenkins/job/myjob/",
"jobName": "myjob",
"gitCommit": "abc123",
"buildNumber": 42,
"buildID": 42,
"buildDisplayName": "#42",
"gitBranch": "main",
"buildTag": "myjob-42"
}
''';
const buildYaml = 'property:\n workspace: .\ncommands: []\n';
final fake = FakeSystemRuntime(
isLinux: true,
runShellStdout: jenkinsJson,
startShellStdout: buildYaml,
);
final collector = JenkinsEnvironmentCollector(runtime: fake);
final snapshot = await collector.collect(const NoopRunnerOutputPort());
expect(snapshot, isNotNull);
expect(snapshot!.variables['workspace'], '/workspace');
expect(snapshot.buildYaml, buildYaml);
expect(snapshot.rawVariablesJson, jenkinsJson);
expect(fake.runShellCalls, hasLength(1));
expect(fake.startShellCalls, hasLength(1));
expect(fake.startShellCalls.single.printStdout, isFalse);
},
);
test(
'JenkinsEnvironmentCollector emits unsupported OS through output port',
() async {
final fake = FakeSystemRuntime(
isWindows: false,
isMacOS: false,
isLinux: false,
);
final output = _RecordingOutputPort();
final collector = JenkinsEnvironmentCollector(runtime: fake);
final snapshot = await collector.collect(output);
expect(snapshot, isNull);
expect(output.steps.single.message, contains('is not Mac/Windows/Linux'));
expect(output.steps.single.style, RunnerOutputStyle.error);
},
);
test('fake system runtime records process calls', () async {
final fake = FakeSystemRuntime(
isWindows: false,
isMacOS: false,
isLinux: true,
environment: {'HOME': '/home/test'},
);
// startShell
await fake.startShell(
StringBuffer('echo hello'),
workspace: '/tmp/test',
printStdout: false,
printStderr: false,
);
expect(fake.startShellCalls, hasLength(1));
expect(fake.startShellCalls.single.shell, 'echo hello');
expect(fake.startShellCalls.single.workspace, '/tmp/test');
expect(fake.startShellCalls.single.printStdout, isFalse);
expect(fake.startShellCalls.single.printStderr, isFalse);
expect(fake.startShellCalls.single.decoder, isNull);
// runShell
await fake.runShell(StringBuffer('ls -la'), workspace: '/var/www');
expect(fake.runShellCalls, hasLength(1));
expect(fake.runShellCalls.single.shell, 'ls -la');
expect(fake.runShellCalls.single.workspace, '/var/www');
// runExecutable
await fake.runExecutable('git', [
'status',
'--short',
], workingDirectory: '/repo');
expect(fake.runExecutableCalls, hasLength(1));
expect(fake.runExecutableCalls.single.exe, 'git');
expect(fake.runExecutableCalls.single.args, ['status', '--short']);
expect(fake.runExecutableCalls.single.workingDirectory, '/repo');
// startExecutable (detached)
await expectLater(
fake.startExecutable(
'my_daemon',
['--port', '8080'],
workingDirectory: '/srv',
mode: ProcessStartMode.detached,
),
throwsUnsupportedError,
);
expect(fake.startExecutableCalls, hasLength(1));
expect(fake.startExecutableCalls.single.program, 'my_daemon');
expect(fake.startExecutableCalls.single.args, ['--port', '8080']);
expect(fake.startExecutableCalls.single.workingDirectory, '/srv');
expect(fake.startExecutableCalls.single.mode, ProcessStartMode.detached);
// killPid
fake.killPid(12345);
fake.killPid(67890, ProcessSignal.sigkill);
expect(fake.killedPids, [12345, 67890]);
// OS flags
expect(fake.isLinux, isTrue);
expect(fake.isWindows, isFalse);
expect(fake.isMacOS, isFalse);
// environment
expect(fake.environment['HOME'], '/home/test');
});
}
class _OutputRecord {
final String message;
final RunnerOutputStyle style;
_OutputRecord(this.message, this.style);
}
class _RecordingOutputPort implements RunnerOutputPort {
final List<_OutputRecord> lines = [];
final List<_OutputRecord> blocks = [];
final List<_OutputRecord> steps = [];
@override
Future<void> line(
String message, {
RunnerOutputStyle style = RunnerOutputStyle.normal,
}) async {
lines.add(_OutputRecord(message, style));
}
@override
Future<void> block(
String message, {
RunnerOutputStyle style = RunnerOutputStyle.normal,
}) async {
blocks.add(_OutputRecord(message, style));
}
@override
Future<void> buildStep(
String name, {
RunnerOutputStyle style = RunnerOutputStyle.success,
}) async {
steps.add(_OutputRecord(name, style));
}
}