Desktop/dock-launched clide inherits a sparse PATH (no ~/.local/bin, brew, nvm, …), so pql/git/claude and PTY tools went missing. T-347 fixed only the toolchain/pql path on Linux; env.dart's expander was still macOS-only and claude/PTY/git used the raw PATH — the breakage recurred per spawn site because there were three divergent expanders. Consolidate into one resolver (lib/src/env/shell_env.dart): - primeLoginShellPath(): probe the user's real login shell once at startup (`$SHELL -l -c`, sentinel-framed, bounded timeout, graceful fallback to the process PATH). Captures the user's actual PATH, not a hardcoded guess. - expandToolPath(): the canonical merge (moved from toolchain_paths, which re-exports it for its tests) — unions the well-known user/local bin dirs. - resolvedToolPath(): currentSearchPath() + expandToolPath, the single call every spawn site uses. Routed through it: PTY children (registry.dart now overrides PATH), git (env.dart → operations.dart), the toolchain probe (toolchain_paths), and hosted claude (agent_bootstrap). Primed in main.dart's !kIsWeb boot. Deleted the macOS-only env.dart copy and the cli_install copy. Tests: new shell_env_test (probe + every fallback + merge); env_test and cli_install_test updated to the consolidated surface. analyze clean, web wasm build still green, make test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
10 KiB
Dart
219 lines
10 KiB
Dart
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<String, String> env, List<String>? 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/<plat>/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/<platform>/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'));
|
|
});
|
|
});
|
|
}
|