Three holes from the T-511 adversarial review pass: - An entry containing the PATH separator smuggled extra tokens into the joined PATH — a stray trailing ':' yields an EMPTY token, which POSIX shells resolve as CWD (the dot-in-PATH hazard). The CLI verb and the settings control now reject such entries, and applyPathPreset skips malformed stored values that predate the check. - The gitdir pointer a worktree resolution follows is repo-controlled text; the resolved main root is now validated (must hold a real .git directory) before its preset key is trusted, so a crafted pointer can't alias an arbitrary path's preset. - A pane spawned with a cwd below the workspace root hashed the subdirectory and silently missed the workspace preset; the lookup now keys any in-workspace cwd to the workspace root (presetLookupRoot). Also: the Add button pairs buttonBackground with its own buttonHoverBackground token instead of borrowing the list-item hover token, and the hosted-Claude leg gains an end-to-end orchestrator test (preset lookup → spawn env). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
8.3 KiB
Dart
201 lines
8.3 KiB
Dart
/// D-106/T-511: the per-workspace PATH preset settings control. Covers the
|
|
/// no-workspace and empty states, add/remove/reorder against the real
|
|
/// settings store, worktree key sharing, live updates on a CLI-side write,
|
|
/// and the capture-from-login-shell suggestion flow.
|
|
///
|
|
/// Settings writes are real file I/O, so seeding goes through
|
|
/// [WidgetTester.runAsync] — awaiting it inside the fake-async body would hang.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:clide/builtin/tools_settings/src/path_preset_control.dart';
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel;
|
|
import 'package:clide/src/env/path_preset.dart';
|
|
import 'package:clide/src/env/shell_env.dart' show debugResetLoginShellPath, debugSetLoginShellPath;
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../../helpers/kernel_fixture.dart';
|
|
import '../../helpers/widget_harness.dart';
|
|
|
|
void main() {
|
|
late KernelFixture f;
|
|
setUp(() async => f = await KernelFixture.create());
|
|
tearDown(() async => f.dispose());
|
|
|
|
List<String> dirs(String root) => presetDirsFrom((k) => f.services.settings.get<Object>(k), root);
|
|
|
|
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
|
|
harness(
|
|
f,
|
|
const Align(
|
|
alignment: Alignment.center,
|
|
child: SizedBox(width: 460, child: PathPresetControl()),
|
|
),
|
|
),
|
|
);
|
|
|
|
testWidgets('no workspace open → prompts to open one', (tester) async {
|
|
await pump(tester);
|
|
await tester.pump();
|
|
expect(find.textContaining('Open a workspace'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('empty preset shows the hint + the add row', (tester) async {
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
expect(find.textContaining('No preset entries'), findsOneWidget);
|
|
expect(find.text('Add entry'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('typing a dir + Add persists it, publishes on envPathChannel, and renders the row', (tester) async {
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
final published = <Map<String, Object?>>[];
|
|
final sub = f.services.messages.subscribe(channel: envPathChannel).listen((m) => published.add(m.data));
|
|
addTearDown(sub.cancel);
|
|
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
|
|
await tester.tap(find.text('Add entry'));
|
|
await tester.pump();
|
|
|
|
expect(dirs(f.tempDir.path), ['/opt/go/bin']);
|
|
expect(find.text('/opt/go/bin'), findsOneWidget);
|
|
expect(find.text('missing'), findsOneWidget, reason: 'the dir does not exist → warning tag');
|
|
expect(published.single['action'], 'add');
|
|
expect(published.single['dirs'], ['/opt/go/bin']);
|
|
});
|
|
|
|
testWidgets('a relative entry is rejected with a visible reason and nothing is written', (tester) async {
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(EditableText), 'go/bin');
|
|
await tester.tap(find.text('Add entry'));
|
|
await tester.pump();
|
|
expect(find.textContaining('absolute path'), findsOneWidget);
|
|
expect(dirs(f.tempDir.path), isEmpty);
|
|
});
|
|
|
|
testWidgets('an entry containing a PATH separator is rejected (CWD-token guard)', (tester) async {
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(EditableText), '/opt/go/bin:');
|
|
await tester.tap(find.text('Add entry'));
|
|
await tester.pump();
|
|
expect(find.textContaining('absolute path'), findsOneWidget);
|
|
expect(dirs(f.tempDir.path), isEmpty);
|
|
});
|
|
|
|
testWidgets('~/ expands against HOME and Enter submits (parity with the CLI verb)', (tester) async {
|
|
final home = Platform.environment['HOME'];
|
|
if (home == null || home.isEmpty) return; // no HOME in this environment — the guard path is CLI-tested
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(EditableText), '~/go/bin/');
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
await tester.pump();
|
|
expect(dirs(f.tempDir.path), ['$home/go/bin']);
|
|
});
|
|
|
|
testWidgets('an existing dir renders without the missing tag', (tester) async {
|
|
await tester.runAsync(() async {
|
|
await f.services.settings.setProjectDir(f.tempDir);
|
|
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), [f.tempDir.path]);
|
|
});
|
|
await pump(tester);
|
|
await tester.pump();
|
|
expect(find.text(f.tempDir.path), findsOneWidget);
|
|
expect(find.text('missing'), findsNothing);
|
|
});
|
|
|
|
testWidgets('remove and reorder rewrite the stored order', (tester) async {
|
|
await tester.runAsync(() async {
|
|
await f.services.settings.setProjectDir(f.tempDir);
|
|
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/a', '/b', '/c']);
|
|
});
|
|
await pump(tester);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.bySemanticsLabel('Move down: /a'));
|
|
await tester.pump();
|
|
expect(dirs(f.tempDir.path), ['/b', '/a', '/c']);
|
|
|
|
await tester.tap(find.bySemanticsLabel('Move up: /c'));
|
|
await tester.pump();
|
|
expect(dirs(f.tempDir.path), ['/b', '/c', '/a']);
|
|
|
|
await tester.tap(find.bySemanticsLabel('Remove: /b'));
|
|
await tester.pump();
|
|
expect(dirs(f.tempDir.path), ['/c', '/a']);
|
|
});
|
|
|
|
testWidgets('live-updates when the preset changes from outside (CLI-side write)', (tester) async {
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
expect(find.textContaining('No preset entries'), findsOneWidget);
|
|
|
|
await tester.runAsync(() => f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/from/cli']));
|
|
await tester.pump();
|
|
expect(find.text('/from/cli'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('opened from an in-repo worktree: says so and edits the main repo key (D-106)', (tester) async {
|
|
late Directory repo;
|
|
late Directory wt;
|
|
await tester.runAsync(() async {
|
|
repo = Directory('${f.tempDir.path}/repo')..createSync();
|
|
Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true);
|
|
wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true);
|
|
File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n');
|
|
await f.services.settings.setProjectDir(wt);
|
|
});
|
|
|
|
await pump(tester);
|
|
await tester.pump();
|
|
expect(find.textContaining('Worktree'), findsOneWidget);
|
|
expect(find.text(repo.path), findsOneWidget, reason: 'names the shared main repo root');
|
|
|
|
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
|
|
await tester.tap(find.text('Add entry'));
|
|
await tester.pump();
|
|
expect(dirs(repo.path), ['/opt/go/bin'], reason: 'worktree writes land on the main repo key');
|
|
expect(dirs(wt.path), ['/opt/go/bin'], reason: 'reading via the worktree resolves the same key');
|
|
});
|
|
|
|
testWidgets('capture suggests login-shell dirs and a tap adopts one', (tester) async {
|
|
debugSetLoginShellPath('/cap-a:${Platform.environment['PATH'] ?? ''}');
|
|
addTearDown(debugResetLoginShellPath);
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.tap(find.text('Suggest from login shell'));
|
|
await tester.pump();
|
|
expect(find.text('/cap-a'), findsOneWidget);
|
|
|
|
await tester.tap(find.bySemanticsLabel('Add suggested entry: /cap-a'));
|
|
await tester.pump();
|
|
expect(dirs(f.tempDir.path), ['/cap-a']);
|
|
expect(find.bySemanticsLabel('Add suggested entry: /cap-a'), findsNothing, reason: 'adopted suggestion leaves the list');
|
|
});
|
|
|
|
testWidgets('capture with no login-shell probe reports nothing to suggest', (tester) async {
|
|
debugResetLoginShellPath();
|
|
addTearDown(debugResetLoginShellPath);
|
|
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
|
await pump(tester);
|
|
await tester.pump();
|
|
await tester.tap(find.text('Suggest from login shell'));
|
|
await tester.pump();
|
|
expect(find.textContaining('Nothing to suggest'), findsOneWidget);
|
|
});
|
|
}
|