Files
clide/test/kernel/src/project_test.dart
T
jpmschweitzerandClaude Fable 5 6817abaf96 add WorkspaceRef + remote identity on RecentProject (T-332)
The model-independent half of the ssh:// open scheme. WorkspaceRef is
the value type for "where a workspace lives" — a local path or
ssh://[user@]host[:port]/abs/path, with parse/uri round-tripping and a
host:path display form. RecentProject carries host/port/user
(back-compatible JSON: absent keys deserialize as local) so remote
recents survive restarts and render with their host badge.

The remaining T-332 scope — ProjectManager.current off bare Directory,
open() branching, remote resolveProject — is gated on the execution
layer (T-336), which is itself blocked on the T-330 footprint pick;
the epic's blocker graph now encodes that gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:11:57 +02:00

334 lines
14 KiB
Dart

/// Unit tests for `ProjectManager` and `RecentProject` in
/// `lib/kernel/src/project.dart`. Uses an injected `onValidateProject`
/// hook so the manager doesn't shell out to git in tests.
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:test/test.dart';
Future<ProjectManager> _build({
Future<String?> Function(String path)? onValidateProject,
Future<void> Function(String path)? onProjectOpen,
Map<String, Object?> initialSettings = const {},
}) async {
final tmp = await Directory.systemTemp.createTemp('clide-project-test-');
final settings = SettingsStore(appDir: tmp);
await settings.load();
for (final entry in initialSettings.entries) {
await settings.set<Object>(entry.key, entry.value!);
}
final toolchain = Toolchain();
toolchain.applyResolved(const ResolvedPaths(git: '/usr/bin/git'));
final pm = ProjectManager(
log: Logger(minLevel: LogLevel.error),
events: DaemonBus(),
settings: settings,
toolchain: toolchain,
onValidateProject: onValidateProject,
onProjectOpen: onProjectOpen,
);
addTearDown(() async {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
});
return pm;
}
void main() {
group('RecentProject', () {
test(r'relativePath collapses $HOME prefix to ~', () {
final home = Platform.environment['HOME'] ?? '';
if (home.isEmpty) return;
final p = RecentProject(path: '$home/projects/clide', name: 'clide', lastOpened: DateTime.now());
expect(p.relativePath, startsWith('~/'));
});
test(r'relativePath returns the absolute path when not under $HOME', () {
final p = RecentProject(path: '/elsewhere/repo', name: 'repo', lastOpened: DateTime.now());
// If $HOME is /elsewhere... we'd collapse. Otherwise absolute is preserved.
if (!'/elsewhere/repo'.startsWith(Platform.environment['HOME'] ?? '')) {
expect(p.relativePath, '/elsewhere/repo');
}
});
test('timeAgo buckets walk minutes → hours → days → weeks → months', () {
final now = DateTime.now();
RecentProject pq(Duration ago) => RecentProject(path: '/p', name: 'p', lastOpened: now.subtract(ago));
expect(pq(const Duration(seconds: 10)).timeAgo, 'just now');
expect(pq(const Duration(minutes: 5)).timeAgo, contains('min'));
expect(pq(const Duration(hours: 3)).timeAgo, contains('hours'));
expect(pq(const Duration(days: 1, hours: 1)).timeAgo, 'yesterday');
expect(pq(const Duration(days: 3)).timeAgo, contains('days'));
expect(pq(const Duration(days: 14)).timeAgo, contains('weeks'));
expect(pq(const Duration(days: 90)).timeAgo, contains('months'));
});
test('toJson / fromJson round-trip', () {
final p = RecentProject(path: '/p', name: 'p', branch: 'main', lastOpened: DateTime.utc(2026, 5, 1, 12));
final back = RecentProject.fromJson(p.toJson());
expect(back.path, '/p');
expect(back.name, 'p');
expect(back.branch, 'main');
expect(back.lastOpened, p.lastOpened);
});
test('remote recent round-trips host/port/user and stays remote (T-332)', () {
final p = RecentProject(path: '/srv/repo', name: 'repo', lastOpened: DateTime.utc(2026, 6, 1), host: 'buildbox', port: 2222, user: 'jeroen');
expect(p.isRemote, isTrue);
final json = p.toJson();
expect(json['host'], 'buildbox');
final back = RecentProject.fromJson(json);
expect(back.host, 'buildbox');
expect(back.port, 2222);
expect(back.user, 'jeroen');
expect(back.isRemote, isTrue);
expect(back.ref, WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo', port: 2222, user: 'jeroen'));
// sticky toggles must not drop the host identity
expect(back.copyWith(startupSticky: true).host, 'buildbox');
});
test('local recent serializes without host keys and deserializes local (back-compat)', () {
final p = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.utc(2026, 6, 1));
expect(p.isRemote, isFalse);
expect(p.toJson().containsKey('host'), isFalse);
// A pre-T-332 persisted recent has no host/port/user keys at all.
final old = RecentProject.fromJson(const {'path': '/p', 'name': 'p'});
expect(old.isRemote, isFalse);
expect(old.ref, const WorkspaceRef.local('/p'));
});
test('relativePath shows host:path for remote recents, never ~-collapses', () {
final home = Platform.environment['HOME'] ?? '/home/x';
final p = RecentProject(path: '$home/repo', name: 'repo', lastOpened: DateTime.now(), host: 'buildbox');
expect(p.relativePath, 'buildbox:$home/repo');
});
test('fromJson tolerates missing/invalid fields', () {
final r = RecentProject.fromJson(const {});
expect(r.path, '');
expect(r.name, '');
expect(r.branch, isNull);
});
});
group('ProjectManager', () {
test('open() with a non-git path returns false and leaves _current null', () async {
final pm = await _build(onValidateProject: (_) async => null);
expect(await pm.open('/tmp/not-a-git-repo'), isFalse);
expect(pm.isOpen, isFalse);
});
test('open() with a valid path sets current + emits ProjectOpened', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
expect(await pm.open(sandbox.path), isTrue);
expect(pm.isOpen, isTrue);
expect(pm.current?.path, sandbox.path);
});
test('open() inserts the project into recents (newest first)', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
await pm.open(sandbox.path);
expect(pm.recents, hasLength(1));
expect(pm.recents.first.path, sandbox.path);
});
test('opening the same path twice deduplicates and bumps it to the top', () async {
final a = await Directory.systemTemp.createTemp('clide-pm-a-');
final b = await Directory.systemTemp.createTemp('clide-pm-b-');
addTearDown(() {
a.deleteSync(recursive: true);
b.deleteSync(recursive: true);
});
final pm = await _build(onValidateProject: (p) async => p);
await pm.open(a.path);
await pm.open(b.path);
await pm.open(a.path); // re-open a → moves to front
expect(pm.recents.map((r) => r.path), [a.path, b.path]);
});
test('recents is capped at 10', () async {
final dirs = [for (var i = 0; i < 12; i++) await Directory.systemTemp.createTemp('clide-pm-$i-')];
addTearDown(() {
for (final d in dirs) {
if (d.existsSync()) d.deleteSync(recursive: true);
}
});
final pm = await _build(onValidateProject: (p) async => p);
for (final d in dirs) {
await pm.open(d.path);
}
expect(pm.recents, hasLength(10));
});
test('close() resets _current and emits ProjectClosed', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
await pm.open(sandbox.path);
await pm.close();
expect(pm.isOpen, isFalse);
// close on an already-closed manager is a no-op.
await pm.close();
});
test('loadRecents reads the serialised list from settings', () async {
final earlier = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.utc(2026, 1, 1));
final pm = await _build(
initialSettings: {
'app.recentProjects': jsonEncode([earlier.toJson()]),
},
);
await pm.loadRecents();
expect(pm.recents, hasLength(1));
expect(pm.recents.first.path, '/p');
});
test('loadRecents tolerates a malformed value', () async {
final pm = await _build(initialSettings: {'app.recentProjects': '{not json'});
await pm.loadRecents();
expect(pm.recents, isEmpty);
});
test('loadRecents with no setting clears to empty', () async {
final pm = await _build();
await pm.loadRecents();
expect(pm.recents, isEmpty);
});
test('openLast returns false when no last-project is stored', () async {
final pm = await _build();
expect(await pm.openLast(), isFalse);
});
test('openLast returns false when the stored path no longer exists', () async {
final pm = await _build(initialSettings: {'app.lastProject': '/tmp/clide-no-such-${DateTime.now().microsecondsSinceEpoch}'});
expect(await pm.openLast(), isFalse);
});
test('openLast re-opens the stored project when it still exists', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-last-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(initialSettings: {'app.lastProject': sandbox.path}, onValidateProject: (_) async => sandbox.path);
expect(await pm.openLast(), isTrue);
expect(pm.current?.path, sandbox.path);
});
test('resolveProject without an injected validator falls back to Process.run', () async {
// No onValidateProject → uses Toolchain.git. With a real git in a tempdir
// that's not a repo, returns null.
final pm = await _build();
final notRepo = await Directory.systemTemp.createTemp('clide-pm-not-');
addTearDown(() => notRepo.deleteSync(recursive: true));
expect(await pm.resolveProject(notRepo.path), isNull);
});
test('stickyProjectPath is null with zero sticky recents', () async {
final pm = await _build();
expect(pm.stickyProjectPath, isNull);
});
test('setStickyStartup is a no-op for an unknown path', () async {
final pm = await _build();
await pm.setStickyStartup('/nope', true);
expect(pm.stickyProjectPath, isNull);
});
test('setStickyStartup flips, persists, and notifies listeners', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-sticky-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
expect(await pm.open(sandbox.path), isTrue);
var calls = 0;
pm.addListener(() => calls++);
await pm.setStickyStartup(sandbox.path, true);
expect(pm.isStickyStartup(sandbox.path), isTrue);
expect(pm.stickyProjectPath, sandbox.path);
expect(calls, 1);
// Idempotent — second call with same value does nothing.
await pm.setStickyStartup(sandbox.path, true);
expect(calls, 1);
// Flip back.
await pm.setStickyStartup(sandbox.path, false);
expect(pm.isStickyStartup(sandbox.path), isFalse);
expect(pm.stickyProjectPath, isNull);
});
test('sticky flag survives a reopen of the same project', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-sticky-reopen-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
await pm.open(sandbox.path);
await pm.setStickyStartup(sandbox.path, true);
// Reopen — sticky must persist.
await pm.open(sandbox.path);
expect(pm.isStickyStartup(sandbox.path), isTrue);
});
test('stickyProjectPath is null when two recents are sticky', () async {
final a = await Directory.systemTemp.createTemp('clide-pm-sticky-a-');
final b = await Directory.systemTemp.createTemp('clide-pm-sticky-b-');
addTearDown(() => a.deleteSync(recursive: true));
addTearDown(() => b.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (p) async => p);
await pm.open(a.path);
await pm.open(b.path);
await pm.setStickyStartup(a.path, true);
await pm.setStickyStartup(b.path, true);
// Ambiguous — picker should still show.
expect(pm.stickyProjectPath, isNull);
});
test('openStickyOrNothing returns false when none sticky', () async {
final pm = await _build();
expect(await pm.openStickyOrNothing(), isFalse);
});
test('openStickyOrNothing opens the lone sticky project', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-open-sticky-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final pm = await _build(onValidateProject: (_) async => sandbox.path);
await pm.open(sandbox.path);
await pm.setStickyStartup(sandbox.path, true);
// Close, then reload from settings to simulate boot.
await pm.close();
final pm2 = await _build(
onValidateProject: (_) async => sandbox.path,
initialSettings: {
'app.recentProjects': jsonEncode([
{'path': sandbox.path, 'name': 'pm-open-sticky', 'lastOpened': DateTime.now().toIso8601String(), 'startupSticky': true},
]),
},
);
await pm2.loadRecents();
expect(await pm2.openStickyOrNothing(), isTrue);
expect(pm2.current?.path, sandbox.path);
});
test('open() awaits the onProjectOpen hook with the resolved root', () async {
final sandbox = await Directory.systemTemp.createTemp('clide-pm-onopen-');
addTearDown(() => sandbox.deleteSync(recursive: true));
final seen = <String>[];
final pm = await _build(onValidateProject: (_) async => sandbox.path, onProjectOpen: (path) async => seen.add(path));
expect(await pm.open(sandbox.path), isTrue);
expect(seen, [sandbox.path]);
});
test('startupSticky round-trips through toJson/fromJson', () {
final p = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.now(), startupSticky: true);
final j = p.toJson();
expect(j['startupSticky'], isTrue);
final p2 = RecentProject.fromJson(j);
expect(p2.startupSticky, isTrue);
// Default (not sticky) omits the key for cleaner JSON.
final q = RecentProject(path: '/q', name: 'q', lastOpened: DateTime.now());
expect(q.toJson().containsKey('startupSticky'), isFalse);
});
});
}