2 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.7 b1e9abd6aa test sweep: cover daemon editor / files / pane / pql commands (T-91)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
Extends the four existing daemon command suites with the verbs +
error paths the originals didn't reach:

- editor_commands_test (12 new): unreadable-path FileSystemException
  catch, editor.active with no buffer, editor.activate requires +
  validates id, editor.read no-active / unknown-id, editor.set-selection
  no-active / clamped, editor.set-content with + without selection,
  editor.save no-active, editor.close requires + validates id.
- files_commands_test (8 new): files.read happy + missing-path +
  empty-path + outside-root + missing-file, files.ls outside-root,
  files.watch idempotent, FilesService.atCwd resolver.
- pane_commands_test (10 new): argv-non-string rejection, unknown
  kind rejection, env passthrough, close / write / focus / resize
  missing-id and unknown-id validations, write requires bytes_b64
  or text, malformed base64 rejection.
- pql_commands_test (14 new): pql.files glob + limit, pql.backlinks
  happy, pql.outlinks missing, pql.tags, pql.query + pql.search
  happy paths + missing-arg user_error, pql.decisions.read missing
  + happy, pql.decisions.show with --with-refs / --with-tickets,
  pql.decisions.list domain filter, pql.tickets.list multi-filter,
  pql.tickets.show missing + happy, pql.tickets.status missing +
  partial-args, pql.tickets.board with team.

Coverage: src/daemon/editor_commands.dart 64/100 -> 88/100;
files_commands.dart 33/70 -> 64/70 (91%); pane_commands.dart 66/92
-> 78/92 (85%); pql_commands.dart 62/149 -> 105/149 (70% — remaining
44 lines are the per-command PqlException catch branches that only
fire when the pql subprocess itself fails mid-call).

Total coverage 77.92% -> 79.26%; floor bumped to 79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:13:38 +02:00
jpmschweitzerandClaude Opus 4.7 8eaf7446a1 test sweep: cover daemon dispatcher + git_commands (T-91)
Two test additions toward finishing src/daemon/:

- test/daemon/dispatcher_test.dart (6 tests): the entire
  DaemonDispatcher surface — ping + version default handlers,
  unknown-command not-found error, register routing, isEmpty
  before/after registration, clear preserving ping + version.
- test/daemon/git_commands_test.dart extended (14 new tests): the
  git.* commands the existing suite didn't reach — git.diff with
  paths, git.stage-hunk + git.unstage-hunk (happy + missing-patch
  + bad-patch GitException), git.branches, git.checkout (happy +
  missing + unknown), git.log with count, git.push + git.pull both
  with and without a local bare remote, git.stage accepting a
  String single-path arg via _pathList.

Coverage: src/daemon/dispatcher.dart 8/22 -> 22/22 (100%);
src/daemon/git_commands.dart 73/146 -> 122/146 (84%). The
remaining 24 lines in git_commands are mid-call GitException
catch branches that need the git client to fail after the
dispatcher accepted the request.

Total coverage 77.05% -> 77.92%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:09:33 +02:00
7 changed files with 508 additions and 1 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide
# Pre-push line-coverage floor. Ratchets up only — see D-66.
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
coverage_floor: 77
coverage_floor: 79
# Project metadata (was project.yaml, folded in per D-056).
# version: above is the single source of truth. The Makefile reads
+69
View File
@@ -0,0 +1,69 @@
/// Unit tests for the daemon's command dispatcher.
library;
import 'package:clide/clide.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:test/test.dart';
IpcRequest _req(String cmd, {String id = '1', Map<String, Object?> args = const {}}) {
return IpcRequest(id: id, cmd: cmd, args: args);
}
void main() {
group('DaemonDispatcher', () {
test('ping is registered by default and returns pong + version', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('ping'));
expect(r.ok, isTrue);
expect(r.data['pong'], isTrue);
expect(r.data['version'], isNotNull);
expect(r.data['ts'], isA<String>());
});
test('version returns the bundled version string', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('version'));
expect(r.ok, isTrue);
expect(r.data['version'], clideVersion);
});
test('dispatching an unknown command produces a not-found error', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('nonsense'));
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.notFound);
expect(r.error?.message, contains('unknown command'));
});
test('register routes a new handler', () async {
final d = DaemonDispatcher();
d.register('echo', (req) async {
return IpcResponse.ok(id: req.id, data: {'echo': req.args['text']});
});
final r = await d.dispatch(_req('echo', args: {'text': 'hi'}));
expect(r.ok, isTrue);
expect(r.data['echo'], 'hi');
});
test('isEmpty is true for a fresh dispatcher (ping + version only)', () {
final d = DaemonDispatcher();
expect(d.isEmpty, isTrue);
d.register('something', (req) async => IpcResponse.ok(id: req.id, data: const {}));
expect(d.isEmpty, isFalse);
});
test('clear removes user handlers but keeps ping + version', () async {
final d = DaemonDispatcher();
d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {}));
expect(d.isEmpty, isFalse);
d.clear();
expect(d.isEmpty, isTrue);
// ping still works
final r = await d.dispatch(_req('ping'));
expect(r.ok, isTrue);
// 'extra' is gone
final r2 = await d.dispatch(_req('extra'));
expect(r2.ok, isFalse);
});
});
}
+96
View File
@@ -107,4 +107,100 @@ void main() {
expect(r.ok, isFalse);
expect(r.error!.code, IpcExitCode.notFound);
});
test('editor.open returns toolError for an unreadable path', () async {
// Create a file then chmod 000 so reading fails with a FileSystemException.
final unreadable = File('${sandbox.path}/locked.md');
await unreadable.writeAsString('x');
await Process.run('chmod', ['000', unreadable.path]);
addTearDown(() async {
await Process.run('chmod', ['644', unreadable.path]);
});
final r = await call('editor.open', {'path': 'locked.md'});
expect(r.ok, isFalse);
// Either errno-mapped or toolError — either is acceptable.
expect(r.error!.code, isNot(IpcExitCode.notFound));
});
test('editor.active returns null when no buffer is open', () async {
final r = await call('editor.active');
expect(r.ok, isTrue);
expect(r.data['active'], isNull);
});
test('editor.activate requires id and validates it', () async {
final missing = await call('editor.activate');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final unknown = await call('editor.activate', {'id': 'b_404'});
expect(unknown.ok, isFalse);
expect(unknown.error!.kind, 'not_found');
});
test('editor.activate flips the active buffer to the requested one', () async {
await File('${sandbox.path}/a.md').writeAsString('a');
await File('${sandbox.path}/b.md').writeAsString('b');
final a = await call('editor.open', {'path': 'a.md'});
await call('editor.open', {'path': 'b.md'});
final r = await call('editor.activate', {'id': a.data['id']});
expect(r.ok, isTrue);
expect(r.data['active'], a.data['id']);
});
test('editor.read with no active buffer and no id returns not-found', () async {
final r = await call('editor.read');
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('editor.read with an unknown id returns not-found', () async {
final r = await call('editor.read', {'id': 'b_404'});
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('editor.set-selection clamps and applies', () async {
await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.set-selection', {
'selection': {'start': 0, 'end': 3}
});
expect(r.ok, isTrue);
});
test('editor.set-selection without an id or active buffer returns not-found', () async {
final r = await call('editor.set-selection', {
'selection': {'start': 0, 'end': 1}
});
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('editor.set-content overwrites the buffer (with and without selection)', () async {
await call('editor.open', {'path': 'doc.md'});
final r1 = await call('editor.set-content', {'text': 'replaced'});
expect(r1.ok, isTrue);
expect(r1.data['length'], 'replaced'.length);
final read1 = await call('editor.read');
expect(read1.data['content'], 'replaced');
final r2 = await call('editor.set-content', {
'text': 'short',
'selection': {'start': 1, 'end': 99}
});
expect(r2.ok, isTrue);
});
test('editor.save with no active buffer returns not-found', () async {
final r = await call('editor.save');
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('editor.close requires id and validates it', () async {
final missing = await call('editor.close');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final unknown = await call('editor.close', {'id': 'b_404'});
expect(unknown.ok, isFalse);
expect(unknown.error!.kind, 'not_found');
});
}
+50
View File
@@ -78,4 +78,54 @@ void main() {
expect(r.ok, isTrue);
expect(r.data['subscribed'], isTrue);
});
test('files.read returns the file content', () async {
final r = await call('files.read', const {'path': 'README.md'});
expect(r.ok, isTrue);
expect(r.data['content'], 'hi');
expect(r.data['path'], 'README.md');
});
test('files.read without a path returns toolError', () async {
final r = await call('files.read', const {});
expect(r.ok, isFalse);
expect(r.error!.kind, IpcErrorKind.toolError);
expect(r.error!.message, contains('path'));
});
test('files.read with an empty string path returns toolError', () async {
final r = await call('files.read', const {'path': ''});
expect(r.ok, isFalse);
});
test('files.read with a path outside the root is rejected', () async {
final r = await call('files.read', const {'path': '../escape.txt'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('outside workspace'));
});
test('files.read returns toolError for a missing file', () async {
final r = await call('files.read', const {'path': 'does-not-exist.md'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('not found'));
});
test('files.ls with a path outside the root is rejected', () async {
final r = await call('files.ls', const {'path': '../escape'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('outside workspace'));
});
test('files.watch is idempotent: a second call still acks subscription', () async {
final r1 = await call('files.watch', const {});
final r2 = await call('files.watch', const {});
expect(r1.ok, isTrue);
expect(r2.ok, isTrue);
});
test('FilesService.atCwd resolves a workspace root', () {
final svc = FilesService.atCwd(events: RecordingEventSink());
expect(svc.root.existsSync(), isTrue);
addTearDown(svc.shutdown);
});
}
+107
View File
@@ -188,4 +188,111 @@ void main() {
content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'stash-me');
});
test('git.diff with explicit paths narrows the result', () async {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
await File('${sandbox.path}/other.txt').writeAsString('o');
final r = await call('git.diff', {
'paths': ['file.txt']
});
expect(r.ok, isTrue);
final diffs = r.data['diffs'] as List;
expect(diffs, hasLength(1));
});
test('git.stage-hunk requires a non-empty patch', () async {
final missing = await call('git.stage-hunk');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
final empty = await call('git.stage-hunk', {'patch': ''});
expect(empty.ok, isFalse);
});
test('git.unstage-hunk requires a non-empty patch', () async {
final missing = await call('git.unstage-hunk');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
});
test('git.stage-hunk + git.unstage-hunk round-trip a real patch', () async {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
final p = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
final patch = p.stdout as String;
final staged = await call('git.stage-hunk', {'patch': patch});
expect(staged.ok, isTrue);
final unstaged = await call('git.unstage-hunk', {'patch': patch});
expect(unstaged.ok, isTrue);
});
test('git.stage-hunk surfaces GitException as a tool error', () async {
final r = await call('git.stage-hunk', {'patch': 'not a valid patch\n'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.branches lists the local branches', () async {
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
final r = await call('git.branches');
expect(r.ok, isTrue);
final branches = r.data['branches'] as List;
expect(branches.map((b) => (b as Map)['name']), containsAll(['feature/a']));
});
test('git.checkout requires a branch name', () async {
final missing = await call('git.checkout');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
final empty = await call('git.checkout', {'branch': ''});
expect(empty.ok, isFalse);
});
test('git.checkout switches branches', () async {
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
final r = await call('git.checkout', {'branch': 'next'});
expect(r.ok, isTrue);
expect(r.data['branch'], 'next');
});
test('git.checkout to an unknown branch surfaces a tool error', () async {
final r = await call('git.checkout', {'branch': 'no-such-branch'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.log accepts an explicit count', () async {
final r = await call('git.log', {'count': 5});
expect(r.ok, isTrue);
final entries = r.data['entries'] as List;
expect(entries, isNotEmpty);
});
test('git.push to no remote surfaces a tool error', () async {
final r = await call('git.push');
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.pull with no remote surfaces a tool error', () async {
final r = await call('git.pull');
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.push + git.pull against a local bare remote return output', () async {
final remote = await Directory.systemTemp.createTemp('clide-git-cmd-remote-');
addTearDown(() => remote.deleteSync(recursive: true));
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
final pushed = await call('git.push', {'remote': 'origin', 'branch': 'HEAD', 'setUpstream': true});
expect(pushed.ok, isTrue);
final pulled = await call('git.pull');
expect(pulled.ok, isTrue);
});
test('git.stage accepts a string single-path arg', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
// _pathList accepts a String, wrapping it as a singleton.
final r = await call('git.stage', {'paths': 'new.txt'});
expect(r.ok, isTrue);
});
}
+81
View File
@@ -111,5 +111,86 @@ void main() {
expect(r.ok, isTrue);
expect(r.data['subscribed'], isTrue);
});
test('pane.spawn rejects non-string argv entries', () async {
final r = await call('pane.spawn', const {
'argv': ['/bin/sh', 42]
});
expect(r.ok, isFalse);
expect(r.error!.message, contains('strings'));
});
test('pane.spawn rejects an unknown kind', () async {
final r = await call('pane.spawn', const {
'argv': ['/bin/cat'],
'kind': 'no-such-kind',
});
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('pane.spawn passes env through as strings', () async {
final r = await call('pane.spawn', {
'argv': const ['/bin/sh', '-c', 'env'],
'env': const {'FOO': 'bar'},
});
expect(r.ok, isTrue, reason: r.error?.message);
});
test('pane.close requires id and validates it', () async {
final missing = await call('pane.close', const {});
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final unknown = await call('pane.close', const {'id': 'p_404'});
expect(unknown.ok, isFalse);
expect(unknown.error!.kind, 'not_found');
});
test('pane.write requires id and validates it', () async {
final missing = await call('pane.write', const {'text': 'x'});
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
});
test('pane.write requires bytes_b64 or text', () async {
final spawn = await call('pane.spawn', {
'argv': const ['/bin/cat'],
});
final id = spawn.data['id']! as String;
final r = await call('pane.write', {'id': id});
expect(r.ok, isFalse);
expect(r.error!.message, contains('bytes_b64 or text'));
});
test('pane.write rejects malformed base64', () async {
final spawn = await call('pane.spawn', {
'argv': const ['/bin/cat'],
});
final id = spawn.data['id']! as String;
final r = await call('pane.write', {'id': id, 'bytes_b64': 'not-base64!!!'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('base64'));
});
test('pane.resize requires all three of id / cols / rows', () async {
final r = await call('pane.resize', const {'id': 'p_1'});
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('pane.resize on unknown id is not-found', () async {
final r = await call('pane.resize', const {'id': 'p_404', 'cols': 80, 'rows': 24});
expect(r.ok, isFalse);
expect(r.error!.code, IpcExitCode.notFound);
});
test('pane.focus requires id and validates it', () async {
final missing = await call('pane.focus', const {});
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final unknown = await call('pane.focus', const {'id': 'p_404'});
expect(unknown.ok, isFalse);
expect(unknown.error!.kind, 'not_found');
});
});
}
+104
View File
@@ -120,4 +120,108 @@ void main() {
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('pql.files with glob + limit narrows the result', () async {
final r = await call('pql.files', {'glob': 'CLAUDE.md', 'limit': 1});
expect(r.ok, isTrue);
final files = r.data['files'] as List;
expect(files.length, lessThanOrEqualTo(1));
});
test('pql.backlinks with a path returns links list', () async {
final r = await call('pql.backlinks', {'path': 'CLAUDE.md'});
expect(r.ok, isTrue);
expect(r.data['links'], isA<List>());
});
test('pql.outlinks without path returns user_error', () async {
final r = await call('pql.outlinks');
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('pql.tags returns a tag list (with limit)', () async {
final r = await call('pql.tags', {'limit': 5});
expect(r.ok, isTrue);
expect(r.data['tags'], isA<List>());
});
test('pql.query with a DSL string returns results', () async {
final r = await call('pql.query', {'query': 'SELECT name', 'limit': 2});
expect(r.ok, isTrue);
expect(r.data['results'], isA<List>());
});
test('pql.search with terms returns hits; missing terms is user_error', () async {
final missing = await call('pql.search');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final hit = await call('pql.search', {'terms': 'clide', 'limit': 2});
expect(hit.ok, isTrue);
});
test('pql.decisions.read requires id; happy path returns the body', () async {
final missing = await call('pql.decisions.read');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final ok = await call('pql.decisions.read', {'id': 'D-1'});
expect(ok.ok, isTrue);
expect(ok.data['id'], 'D-1');
});
test('pql.decisions.show forwards withRefs / withTickets', () async {
final r = await call('pql.decisions.show', {
'id': 'D-1',
'withRefs': true,
'withTickets': true,
});
expect(r.ok, isTrue);
expect(r.data['id'], 'D-1');
});
test('pql.decisions.list with a domain filter narrows', () async {
final r = await call('pql.decisions.list', {'domain': 'architecture'});
expect(r.ok, isTrue);
final decisions = r.data['decisions'] as List;
expect(decisions.every((d) => (d as Map)['domain'] == 'architecture'), isTrue);
});
test('pql.tickets.list with filters narrows + shows status', () async {
final r = await call('pql.tickets.list', {
'status': 'done',
'team': 'whatever',
'assigned': 'no-one',
'decision': 'D-1',
});
expect(r.ok, isTrue);
expect(r.data['tickets'], isA<List>());
});
test('pql.tickets.show requires id; happy path returns the row', () async {
final missing = await call('pql.tickets.show');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final ok = await call('pql.tickets.show', {
'id': 'T-1',
'withContext': true,
'withBlockers': true,
});
expect(ok.ok, isTrue);
});
test('pql.tickets.status requires ids + status', () async {
final missing = await call('pql.tickets.status');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
// Accept both List and String for ids.
final justOne = await call('pql.tickets.status', {'ids': 'T-1'});
expect(justOne.ok, isFalse); // status still missing
expect(justOne.error!.kind, 'user_error');
});
test('pql.tickets.board with team filter', () async {
final r = await call('pql.tickets.board', {'team': 'whatever'});
expect(r.ok, isTrue);
expect(r.data['columns'], isA<List>());
});
}