From 770048fbd23d48de70285bd158f9a40c5cddc3b9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Apr 2026 13:16:38 +0200 Subject: [PATCH] add ClideTestApp for platform integration testing Standalone test harness activated via CLIDE_TESTMODE=true dart-define. Runs a table of existence checks and exec tests against all external binaries (git, pql, tmux, ptyc, zsh), including zsh-passthrough and env variants. Results printed to stdout and shown in a minimal UI. Auto-exits after 15 seconds. make run-testmode launches it with a 60-second kill timer and pipes output to /tmp/clide-testmode.log. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/test_app.dart | 180 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 lib/test_app.dart diff --git a/lib/test_app.dart b/lib/test_app.dart new file mode 100644 index 00000000..9c2b2c88 --- /dev/null +++ b/lib/test_app.dart @@ -0,0 +1,180 @@ +/// ClideTestApp — standalone test harness for platform integration. +/// +/// Launched via `make run-testmode`. Runs a table of exec tests against +/// external binaries, prints results to stdout (captured by the make +/// target), then exits after 15 seconds. Add test cases here whenever +/// you need to isolate a platform issue without the full app. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +import 'kernel/src/toolchain.dart'; +import 'src/pty/env.dart' show expandedPath; + +const _timeout = Duration(seconds: 15); + +class ClideTestApp extends StatefulWidget { + const ClideTestApp({super.key}); + + @override + State createState() => _ClideTestAppState(); +} + +class _ClideTestAppState extends State { + final List<_TestResult> _results = []; + bool _done = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _runTests()); + Timer(_timeout, () { + print('[testmode] timeout reached — exiting'); + exit(0); + }); + } + + Future _runTests() async { + const workspace = String.fromEnvironment('CLIDE_WORKSPACE'); + final workDir = workspace.isNotEmpty ? workspace : Directory.current.path; + + print('[testmode] === ClideTestApp starting ==='); + print('[testmode] workspace=$workDir'); + print('[testmode] cwd=${Directory.current.path}'); + print('[testmode] expandedPath=$expandedPath'); + print('[testmode]'); + + // Resolve toolchain + final tc = Toolchain(); + tc.applyResolved(Toolchain.resolvePaths(workspaceRoot: workDir)); + _log('toolchain.git', tc.git); + _log('toolchain.pql', tc.pql); + _log('toolchain.tmux', tc.tmux); + _log('toolchain.ptyc', tc.ptyc); + _log('toolchain.shell', tc.shell); + _log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', ')); + print('[testmode]'); + + // Existence checks + await _testExists('git', tc.git); + await _testExists('pql', tc.pql); + await _testExists('tmux', tc.tmux); + await _testExists('ptyc', tc.ptyc); + await _testExists('zsh', tc.shell); + print('[testmode]'); + + // Direct exec tests + await _testExec('git --version', tc.git, ['--version'], workDir); + await _testExec('pql --version', tc.pql, ['--version'], workDir); + await _testExec('tmux -V', tc.tmux, ['-V'], workDir); + await _testExec('ptyc (no args)', tc.ptyc, [], workDir); + await _testExec('zsh --version', tc.shell, ['--version'], workDir); + print('[testmode]'); + + // zsh passthrough tests + await _testExec('zsh -c git', '/bin/zsh', ['-c', '${tc.git} --version'], workDir); + await _testExec('zsh -c pql', '/bin/zsh', ['-c', '${tc.pql} --version'], workDir); + await _testExec('zsh -c tmux', '/bin/zsh', ['-c', '${tc.tmux} -V'], workDir); + await _testExec('zsh -c git (bare)', '/bin/zsh', ['-c', 'git --version'], workDir); + print('[testmode]'); + + // git with env (dugite needs GIT_EXEC_PATH) + await _testExec('git --version (with env)', tc.git, ['--version'], workDir, env: tc.gitEnv); + await _testExec('git status (with env)', tc.git, ['status', '--porcelain'], workDir, env: tc.gitEnv); + await _testExec('git rev-parse (with env)', tc.git, ['rev-parse', '--show-toplevel'], workDir, env: tc.gitEnv); + print('[testmode]'); + + print('[testmode] gitEnv = ${tc.gitEnv}'); + print('[testmode]'); + + print('[testmode] === done (${_results.length} tests) ==='); + setState(() => _done = true); + + // Give the UI a moment to render, then exit. + await Future.delayed(const Duration(seconds: 3)); + exit(0); + } + + void _log(String key, String value) { + print('[testmode] $key = $value'); + } + + Future _testExists(String name, String path) async { + final exists = File(path).existsSync(); + final r = _TestResult(name: '$name exists', detail: path, ok: exists, output: exists ? 'yes' : 'NO'); + print('[testmode] exists | $name | path=$path | ${exists ? "yes" : "NO"}'); + setState(() => _results.add(r)); + } + + Future _testExec(String label, String bin, List args, String workDir, {Map? env}) async { + try { + final r = await Process.run(bin, args, workingDirectory: workDir, environment: env) + .timeout(const Duration(seconds: 5)); + final stdout = (r.stdout as String).trim(); + final stderr = (r.stderr as String).trim(); + final firstLine = stdout.isNotEmpty ? stdout.split('\n').first : (stderr.isNotEmpty ? stderr.split('\n').first : '(empty)'); + final ok = r.exitCode == 0 || r.exitCode == 1; // exit 1 is acceptable for some tools + final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: ok, output: 'exit=${r.exitCode} $firstLine'); + print('[testmode] exec | $label | exit=${r.exitCode} | ${ok ? "OK" : "FAIL"} | $firstLine'); + setState(() => _results.add(result)); + } on ProcessException catch (e) { + final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: false, output: 'ProcessException: ${e.message}'); + print('[testmode] exec | $label | EXCEPTION | ${e.message}'); + setState(() => _results.add(result)); + } on TimeoutException { + final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: false, output: 'TIMEOUT (5s)'); + print('[testmode] exec | $label | TIMEOUT'); + setState(() => _results.add(result)); + } + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Container( + color: const Color(0xFF1E1E2E), + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('ClideTestApp', style: TextStyle(color: Color(0xFFCDD6F4), fontSize: 20, fontWeight: FontWeight.bold, decoration: TextDecoration.none)), + const SizedBox(height: 4), + Text(_done ? 'Done — exiting in 3s' : 'Running tests...', style: const TextStyle(color: Color(0xFF6C7086), fontSize: 13, decoration: TextDecoration.none)), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _results.length, + itemBuilder: (ctx, i) { + final r = _results[i]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Text(r.ok ? '●' : '●', style: TextStyle(color: r.ok ? const Color(0xFFA6E3A1) : const Color(0xFFF38BA8), fontSize: 12, decoration: TextDecoration.none)), + const SizedBox(width: 8), + SizedBox(width: 200, child: Text(r.name, style: const TextStyle(color: Color(0xFFCDD6F4), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none))), + Expanded(child: Text(r.output, style: const TextStyle(color: Color(0xFF9399B2), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none), overflow: TextOverflow.ellipsis)), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _TestResult { + const _TestResult({required this.name, required this.detail, required this.ok, required this.output}); + final String name; + final String detail; + final bool ok; + final String output; +}