import 'dart:io'; import 'package:clide/kernel/src/cli_install.dart'; import 'package:flutter_test/flutter_test.dart'; /// Pure-logic tests for the "Install clide command in PATH" affordance /// (T-212). All I/O is synchronous against temp dirs — every external /// dependency of [CliInstaller] is injected, so no real install is touched. /// /// Linux-only assertions: the resolver mirrors `toolchain_paths.dart`, whose /// macOS branch injects homebrew/local bins into PATH; gating to Linux keeps /// the expanded-PATH behaviour deterministic. The CI host is Linux. void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('clide_cli_install_')); tearDown(() => tmp.deleteSync(recursive: true)); /// A stand-in executable file at [path], created with the given [contents]. File touchExec(String path, {String contents = '#!/bin/sh\n'}) { final f = File(path) ..createSync(recursive: true) ..writeAsStringSync(contents); if (!Platform.isWindows) Process.runSync('chmod', ['755', path]); return f; } CliInstaller installer({required String resolvedExecutable, required Map env, List? candidates, String? installDir}) => CliInstaller(resolvedExecutable: resolvedExecutable, env: env, bundledClientCandidates: candidates, installDir: installDir); group('findBundledClient', () { test('returns the first candidate that exists', () { final present = '${tmp.path}/clide-cli'; touchExec(present); final i = installer(resolvedExecutable: '${tmp.path}/clide', env: {'PATH': ''}, candidates: ['${tmp.path}/missing', present]); expect(i.findBundledClient(), present); }); test('returns null when no candidate exists', () { final i = installer(resolvedExecutable: '${tmp.path}/clide', env: {'PATH': ''}, candidates: ['${tmp.path}/nope']); expect(i.findBundledClient(), isNull); }); }); group('inspect', () { test('missing when no clide on PATH', () { final binDir = Directory('${tmp.path}/bin')..createSync(); final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir.path}); expect(i.inspect().state, CliInstallState.missing); }); test('installed when clide is a plain non-GUI binary', () { final binDir = Directory('${tmp.path}/bin')..createSync(); touchExec('${binDir.path}/clide'); final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir.path}); final s = i.inspect(); expect(s.state, CliInstallState.installed); expect(s.pathEntry, '${binDir.path}/clide'); }); test('staleGui when clide symlinks to the running GUI executable', () { final gui = touchExec('${tmp.path}/gui/clide').path; final binDir = Directory('${tmp.path}/bin')..createSync(); Link('${binDir.path}/clide').createSync(gui); final i = installer(resolvedExecutable: gui, env: {'PATH': binDir.path}); final s = i.inspect(); expect(s.state, CliInstallState.staleGui); expect(s.needsInstall, isTrue); }); test('staleGui when clide sits beside a flutter_assets payload', () { // A GUI bundle: runner next to data/flutter_assets/. final bundle = Directory('${tmp.path}/bundle')..createSync(); Directory('${bundle.path}/data/flutter_assets').createSync(recursive: true); touchExec('${bundle.path}/clide'); final binDir = Directory('${tmp.path}/bin')..createSync(); Link('${binDir.path}/clide').createSync('${bundle.path}/clide'); final i = installer( resolvedExecutable: '${tmp.path}/other/clide', // unrelated GUI path env: {'PATH': binDir.path}, ); expect(i.inspect().state, CliInstallState.staleGui); }); test('devTree when clide resolves to a native//clide build (T-256)', () { final dev = touchExec('${tmp.path}/native/linux-x64/clide').path; final binDir = Directory('${tmp.path}/bin')..createSync(); Link('${binDir.path}/clide').createSync(dev); final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir.path}); final s = i.inspect(); expect(s.state, CliInstallState.devTree); // A dev build is intentional — it doesn't prompt a reinstall. expect(s.needsInstall, isFalse); }); }); group('isDevTreeClient', () { test('matches the native//clide build artifacts', () { expect(isDevTreeClient('/repo/native/linux-x64/clide'), isTrue); expect(isDevTreeClient('/repo/native/macos-arm64/clide'), isTrue); expect(isDevTreeClient('native/linux-arm64/clide'), isTrue); }); test('does not match installed or bundled paths', () { expect(isDevTreeClient('/home/x/.local/bin/clide'), isFalse); expect(isDevTreeClient('/usr/local/bin/clide'), isFalse); expect(isDevTreeClient('/opt/clide/bundle/clide-cli'), isFalse); }); }); group('defaults', () { test('install dir and bundled candidates derive from env + exe dir', () { final i = CliInstaller(resolvedExecutable: '/opt/clide/bundle/clide', env: const {'HOME': '/home/dev', 'CLIDE_CLI_BIN': '/dev/tree/clide'}); expect(i.installDir, '/home/dev/.local/bin'); // CLIDE_CLI_BIN override first, then the in-bundle path. expect(i.bundledClientCandidates, ['/dev/tree/clide', '/opt/clide/bundle/clide-cli']); }); test('omits the CLIDE_CLI_BIN candidate when unset', () { final i = CliInstaller(resolvedExecutable: '/opt/clide/bundle/clide', env: const {'HOME': '/home/dev'}); expect(i.bundledClientCandidates, ['/opt/clide/bundle/clide-cli']); }); test('falls back to the process environment when no env is passed', () { // No env → uses Platform.environment; the in-bundle candidate still // derives from the exe dir and the install dir from $HOME. final i = CliInstaller(resolvedExecutable: '/opt/clide/bundle/clide'); expect(i.bundledClientCandidates, contains('/opt/clide/bundle/clide-cli')); expect(i.installDir, endsWith('/.local/bin')); }); }); // PATH expansion moved to the shared resolver (T-439); its logic is covered by // expandToolPath in toolchain_paths_test + shell_env_test. group('install', () { test('fails clearly when no bundled client is present', () { final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': ''}, candidates: ['${tmp.path}/none'], installDir: '${tmp.path}/bin'); final r = i.install(); expect(r.ok, isFalse); expect(r.message, contains('No bundled clide client')); }); test('copies the client, marks it executable, reports onPath', () { final src = touchExec('${tmp.path}/bundle/clide-cli', contents: '#!/bin/sh\necho hi\n'); final binDir = '${tmp.path}/bin'; final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir}, candidates: [src.path], installDir: binDir); final r = i.install(); expect(r.ok, isTrue); expect(r.onPath, isTrue); expect(r.installedPath, '$binDir/clide'); final dest = File('$binDir/clide'); expect(dest.existsSync(), isTrue); expect(dest.readAsStringSync(), src.readAsStringSync()); if (!Platform.isWindows) { final mode = dest.statSync().mode; expect(mode & 0x49, 0x49, reason: 'owner/group/other +x bits set'); } }); test('flags fromDevTree when the source is a dev-tree build (T-256)', () { final src = touchExec('${tmp.path}/native/linux-x64/clide'); final binDir = '${tmp.path}/bin'; final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir}, candidates: [src.path], installDir: binDir); final r = i.install(); expect(r.ok, isTrue); expect(r.fromDevTree, isTrue); expect(r.message, contains('dev-tree build')); }); test('a bundled (non-dev) source is not flagged fromDevTree', () { final src = touchExec('${tmp.path}/bundle/clide-cli'); final binDir = '${tmp.path}/bin'; final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir}, candidates: [src.path], installDir: binDir); expect(i.install().fromDevTree, isFalse); }); test('overwrites a stale symlink rather than following it', () { final src = touchExec('${tmp.path}/bundle/clide-cli', contents: 'NEW'); final gui = touchExec('${tmp.path}/gui/clide', contents: 'GUI').path; final binDir = '${tmp.path}/bin'; Directory(binDir).createSync(); Link('$binDir/clide').createSync(gui); // stale symlink into the GUI final i = installer(resolvedExecutable: gui, env: {'PATH': binDir}, candidates: [src.path], installDir: binDir); expect(i.install().ok, isTrue); // The GUI binary must be untouched; the bin entry is now a real file. expect(File(gui).readAsStringSync(), 'GUI'); expect(FileSystemEntity.isLinkSync('$binDir/clide'), isFalse); expect(File('$binDir/clide').readAsStringSync(), 'NEW'); }); test('reports a FileSystemException as a failed result', () { final src = touchExec('${tmp.path}/bundle/clide-cli'); // installDir path is occupied by a regular file → createSync throws. final blocker = '${tmp.path}/blocked'; File(blocker).writeAsStringSync('not a dir'); final i = installer(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': ''}, candidates: [src.path], installDir: blocker); final r = i.install(); expect(r.ok, isFalse); expect(r.message, contains('Install failed')); }); test('reports onPath:false when the install dir is not on PATH', () { final src = touchExec('${tmp.path}/bundle/clide-cli'); final installDir = '${tmp.path}/elsewhere'; final i = installer( resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': '${tmp.path}/somewhere-else'}, candidates: [src.path], installDir: installDir, ); final r = i.install(); expect(r.ok, isTrue); expect(r.onPath, isFalse); expect(r.message, contains('add $installDir to your PATH')); }); }); }